A payment gateway API built on Stellar for accepting, verifying, and settling payments in XLM, USDC, and any other Stellar asset you configure.
Think Stripe — but settlement happens on the Stellar network instead of through banks.
- Overview
- How It Works
- Features
- Architecture
- Getting Started
- Dashboard
- Configuration
- Rate Limiting
- API Reference
- Payment Resolution Policy
- Webhooks
- Security Model
- Observability
- Deployment
- Database Migrations
- Development
- Contributing
- License
StellarGate turns Stellar payments into a conventional REST API. A merchant creates a payment intent and receives a destination address plus a unique memo. The payer sends funds from any Stellar wallet. StellarGate watches the chain, matches the incoming transaction to the intent, settles it, and delivers a signed webhook to the merchant's application.
The gateway is non-custodial in the strictest sense: it never holds a secret key, never signs, and never submits a Stellar transaction. It only observes the configured gateway account for incoming payments. Refunds and payouts remain the merchant's responsibility.
┌─────────────┐ 1. POST /payments ┌──────────────┐
│ Merchant │ ───────────────────────► │ StellarGate │
│ Application │ ◄─────────────────────── │ │
└─────────────┘ address + memo + id └──────┬───────┘
│ │
│ 2. show payment details │ 3. watch Horizon
▼ │ (SSE stream + poller)
┌─────────────┐ pays address w/ memo ┌──────▼───────┐
│ Payer's │ ───────────────────────► │ Stellar │
│ Wallet │ │ Network │
└─────────────┘ └──────┬───────┘
│
┌─────────────┐ 5. signed webhook ┌──────▼───────┐
│ Merchant │ ◄─────────────────────── │ 4. verify │
│ Application │ payment.completed │ & settle │
└─────────────┘ └──────────────┘
A payment is matched on three independent attributes — memo, destination, and asset — and only then is the amount compared. Transactions that fail on-chain are ignored.
| Capability | Status | Notes |
|---|---|---|
| Payment intents | ✅ | Create, fetch, list with filtering |
| Multi-merchant | ✅ | API-key auth; every payment scoped to a merchant_id |
| Real-time settlement | ✅ | Horizon SSE stream, with an interval poller as reconciler |
| Payment verification | ✅ | Memo + destination + asset + exact stroop amount |
| Over/underpayment handling | ✅ | Distinct statuses and events; underpaid intents accept a top-up |
| Intent expiry | ✅ | Configurable TTL with a payment.expired event |
| Signed webhooks | ✅ | Timestamped HMAC-SHA256, replay-resistant |
| Webhook redrive | ✅ | Background worker recovers deliveries lost to a crash |
| Delivery inspection | ✅ | List attempts and manually redeliver |
| Idempotent creates | ✅ | Via the Idempotency-Key header |
| Cursor pagination | ✅ | Keyset pagination, stable at any depth |
| SSRF protection | ✅ | Webhook targets resolved and filtered, re-checked on every send |
| Rate limiting | ✅ | Per-IP, per-route-bucket |
| API key lifecycle | ✅ | CSPRNG keys, rotation with overlap, instant revocation |
| Data retention | ✅ | Background pruning of aged delivery rows and idempotency keys |
| API versioning | ✅ | /v1 prefix with a documented deprecation policy |
| Prometheus metrics | ✅ | GET /metrics |
| Dashboard UI | ✅ | Served at /dashboard; no build step or separate deploy |
src/
├── main.rs Entry point: boot, background task spawn, graceful shutdown
├── lib.rs Shared AppState and task-health tracking
├── config.rs Environment parsing and validation (fails fast on bad input)
├── db.rs SQLite persistence via sqlx
├── money.rs Stroop-exact amount parsing and canonical serialization
├── strkey.rs Stellar address (strkey) validation
├── ssrf.rs Webhook target resolution and private-range filtering
├── horizon.rs Horizon SSE listener, interval poller, payment verification
├── expiry.rs Background sweeper for overdue pending intents
├── retention.rs Background pruning of aged rows (bounds table growth)
├── metrics.rs Prometheus counters and histograms
├── webhook.rs Signed dispatch and the background redrive worker
└── api/
├── mod.rs Router, auth, rate limiting, CORS, timeouts, dashboard, 404 fallback
└── payments.rs Payment and webhook-delivery handlers
static/ Dashboard assets, compiled into the binary via include_str!
migrations/ Versioned SQL, applied automatically on startup
tests/ Integration tests (API, concurrency, rate limits, webhooks, trustlines)
Amounts are handled in stroops (1 XLM = 10,000,000 stroops) as integers throughout. Floating-point arithmetic is never used for money. Values are canonicalized on write and on serialization, so "10.00", "10.0", and "10" are stored and returned identically.
Two independent listeners run concurrently. The SSE stream gives near-real-time settlement; the interval poller re-scans from a persisted cursor and acts as a reconciler for anything missed during a reconnect. Both converge on the same idempotent settlement path, so a payment observed twice settles once.
Both listeners resume from a persisted cursor across restarts, under separate kv_state keys (horizon_stream_cursor and horizon_payment_cursor) so neither drags the other backwards. A deploy therefore does not cost the stream its ~1 s settlement latency: it reconnects from the last event it handled and works forward through whatever landed while the process was down, rather than re-baselining at the live edge and leaving the gap to the poller. Only a genuinely fresh database — no cursor under either key — starts at the live edge. Re-seeing records the other listener already handled is harmless; settlement is idempotent through processed_transactions.
Shutdown is observed mid-catch-up. A poll cycle checks the shutdown signal at every page boundary, right after it checkpoints its cursor, so SIGTERM during a long backlog drain is honoured in one page rather than after the whole backlog (or, worse, by being killed mid-page once the 30 s shutdown grace expires). POLL_MAX_PAGES_PER_CYCLE bounds a single cycle for the same reason.
| Layer | Choice |
|---|---|
| Language | Rust (2021 edition, 1.88+) |
| HTTP | axum + tower-http |
| Database | SQLite via sqlx (WAL mode) |
| Async runtime | tokio |
| TLS | rustls (no OpenSSL dependency) |
| Chain access | Stellar Horizon API |
- Rust 1.88 or newer — install via rustup
- A Stellar account public key to receive payments (testnet keys: Stellar Laboratory)
git clone https://github.com/StellarGateLabs/StellarGate.git
cd StellarGate
cp .env.example .env
# Edit .env — at minimum set STELLAR_GATEWAY_PUBLIC, WEBHOOK_SECRET,
# and ADMIN_PROVISIONING_SECRET
cargo runThe API listens on http://localhost:3000 by default.
The fastest path if you'd rather not install Rust:
cp .env.example .env # edit as above
docker compose up --buildThe SQLite database lives in a named volume (stellargate_data) and survives container restarts. docker compose down stops the stack while preserving that volume.
# 1. Liveness
curl http://localhost:3000/health
# {"status":"ok"}
# 2. Provision a merchant (requires ADMIN_PROVISIONING_SECRET)
curl -X POST http://localhost:3000/merchants \
-H "X-Admin-Secret: $ADMIN_PROVISIONING_SECRET"
# {"merchant_id":"...","api_key":"..."}
# 3. Create a payment intent
curl -X POST http://localhost:3000/payments \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"amount":"10","asset":"XLM"}'A read-and-operate dashboard is served directly by the gateway at
http://localhost:3000/dashboard — no separate process, build step, or
deploy. Sign in with any merchant API key.
| View | What it does |
|---|---|
| Payments | Table of the merchant's payments, filterable by status, paged with the keyset cursor |
| Payment detail | Full record — amounts, memo, destination, transaction hash, timestamps |
| Webhook deliveries | Every attempt for a payment, with a one-click Redeliver |
| Health | Live /ready indicator, polled every 30s |
How it's built. The page is plain HTML, CSS, and dependency-free
JavaScript, compiled into the binary with include_str!. There is no npm, no
bundler, and no node_modules: the deployable artifact stays a single Rust
binary, and the dashboard cannot drift out of sync with the API it ships
alongside. It is also a plain client of the documented REST API — it uses no
private endpoints, so anything it displays you can fetch yourself.
Security.
- The static shell is unauthenticated because it contains no data. Every figure on the page is fetched by your browser from the same authenticated endpoints documented below, using the key you supply.
- The key is held in your browser (
sessionStorage, orlocalStorageif you tick keep me signed in) and sent as a bearer token. It is never stored server-side and never logged. - Responses carry a strict
Content-Security-Policy(default-src 'none', nounsafe-inline) plusX-Content-Type-Options: nosniff, so the page an operator pastes a key into cannot load third-party script or be framed. - All API-supplied values are inserted via
textContent, never as markup —webhook_urlandmemoare merchant-controlled and would otherwise be a stored-XSS vector.
The dashboard shows only what the signed-in merchant's key can already reach. It exposes no admin capability — merchant provisioning stays on
POST /merchantsbehindADMIN_PROVISIONING_SECRET. If you expose the gateway publicly, put/dashboardbehind your own network controls as well.
All configuration is via environment variables, read once at startup. Invalid values abort boot rather than silently falling back to a default — a typo in an asset issuer or listener mode is a startup failure, not a runtime surprise.
| Variable | Description | Default |
|---|---|---|
PORT |
HTTP listen port | 3000 |
DATABASE_URL |
sqlx connection string (not a file path) | sqlite:stellargate.db |
STELLAR_NETWORK |
testnet or public |
testnet |
STELLAR_HORIZON_URL |
Horizon endpoint | testnet Horizon |
STELLAR_GATEWAY_PUBLIC |
Gateway wallet public key (G…), validated as a strkey at startup. The listener stays idle until this is set. |
— |
ACCEPTED_ASSETS |
Comma-separated. Only native XLM may be written as a bare CODE. Every other asset is CODE:ISSUER (USDC:GA…). A typo like ACCEPTED_ASSETS=XLM,USDC used to treat native XLM as settling USDC intents; boot now refuses it (issue #221). Duplicate codes are also refused (issue #222). Each issuer is strkey-validated. Adding an asset is config-only — but see Trustlines. |
XLM,USDC:<testnet issuer> |
REQUEST_TIMEOUT_SECS |
Whole-request timeout; exceeding it returns 408 |
30 |
MAX_PAYMENT_AMOUNT |
Maximum amount POST /payments accepts, in the asset's own units. A bare number (100000) applies to every asset; CODE:AMOUNT (USDC:50000) pins a bound to one asset specifically and always wins over the default; mix both with commas (100000,USDC:50000). Exceeding it returns 400 amount_out_of_range naming the configured limit — distinct from invalid_amount, which means the value itself is malformed. Unset means no bound beyond i64 overflow in parse_stroops (issue #310). |
unset |
MIN_PAYMENT_AMOUNT |
Minimum amount, configured the same way as MAX_PAYMENT_AMOUNT. Boot refuses a configuration where an asset's effective minimum exceeds its effective maximum. |
unset |
Every non-native asset in ACCEPTED_ASSETS needs a trustline on the gateway
account. This is a Stellar rule, not a StellarGate one: an account cannot hold
an asset it does not trust, so a payment in an untrusted asset fails on-chain
before the gateway ever sees it. Nothing in the API can rescue it — the intent
sits pending until it expires while the payer's transaction is rejected.
XLM is native and never needs one. ACCEPTED_ASSETS defaults to including USDC,
so a fresh account will be missing that trustline.
StellarGate checks at startup and names anything missing:
WARN gateway account has no trustline for an accepted asset;
intents in this asset will be unpayable asset=USDC issuer=GBBD47IF…
INFO accepted assets with no trustline on the gateway account missing=["USDC"]
It is a warning, not a boot failure — accepting XLM only is perfectly valid, so refusing to start would be wrong. Read the first lines of the log after your first deploy.
The check doesn't stop at boot. A trustline can be removed at any time
after startup — by the account operator, or by an issuer revoking
authorization — and an asset can be added to ACCEPTED_ASSETS on a redeploy.
A background trustline_checker task re-runs the same check on the retention
worker's cadence (RETENTION_INTERVAL_SECS, default 1 hour) for as long as
the gateway wallet is configured, so drift after boot is caught rather than
only ever logged once.
Current trustline state is on GET /metrics, so it's alertable rather than
grep-only:
# a confirmed-missing or unauthorized trustline
stellargate_missing_trustlines{asset="USDC"} 1
# trustline exists but is_authorized=false (issuer revoked authorization)
stellargate_trustline_unauthorized{asset="USDC"} 1
# remaining capacity in stroops (limit - balance); alert when near your typical payment size
stellargate_trustline_headroom_stroops{asset="USDC"} 9223372036854775807
# how many checks have failed to reach Horizon at all — distinct from a
# confirmed-absent trustline, which only ever comes from a check that
# actually got an answer
stellargate_trustline_check_failures_total 0
# unix timestamp of the last check that got a confirmed answer; 0 means
# never — treat stellargate_missing_trustlines as unknown, not confirmed,
# until this is nonzero
stellargate_trustline_check_last_success_timestamp_seconds 1732000000
stellargate_missing_trustlines=1 covers two distinct cases — both cause payments
to bounce on-chain:
- No trustline — the account has no balance line for this asset. Add one with
changeTrust. - Unauthorized trustline — the balance line exists but
is_authorized=false. The issuer usesAUTH_REQUIREDand has not yet granted (or has revoked) authorization. Contact the issuer.
stellargate_trustline_unauthorized distinguishes the second case from the first, so an alert can
recommend the right action.
stellargate_trustline_headroom_stroops is the remaining capacity (limit - balance). A payment
that would push the balance past limit fails on-chain just like a missing trustline. Alert when
this approaches your typical payment size.
A Horizon outage during a check only increments
stellargate_trustline_check_failures_total; it leaves the last confirmed
stellargate_missing_trustlines value alone rather than guessing, so "Horizon
is unreachable" and "trustline confirmed absent" never look the same on the
scrape.
Once a trustline is confirmed missing, POST /payments for that asset returns
503 trustline_missing instead of minting an intent that can only bounce
on-chain. An asset that has never been checked yet (or whose last check
errored) is not rejected on that basis alone — an unreachable Horizon
must not also fail every payment creation for assets it simply hasn't had a
chance to confirm.
To check what the account currently trusts directly against Horizon:
curl -s "https://horizon-testnet.stellar.org/accounts/$STELLAR_GATEWAY_PUBLIC" \
| jq '.balances[] | {asset: (.asset_code // "XLM"), issuer: .asset_issuer}'Adding one is a changeTrust operation signed by the gateway account's secret
key — done once, from a wallet or script you control, never by StellarGate,
which holds no secret key and cannot do it for you. Any Stellar wallet
(Lobstr, Freighter) can add a trustline, or use the SDK:
from stellar_sdk import Keypair, Server, TransactionBuilder, Network, Asset
kp = Keypair.from_secret("S...") # gateway account secret
server = Server("https://horizon-testnet.stellar.org")
tx = (TransactionBuilder(
source_account=server.load_account(kp.public_key),
network_passphrase=Network.TESTNET_NETWORK_PASSPHRASE,
base_fee=100)
.append_change_trust_op(asset=Asset("USDC", "GBBD47IF…"))
.set_timeout(90).build())
tx.sign(kp)
server.submit_transaction(tx)Each trustline locks 0.5 XLM of the account's base reserve, so keep enough XLM free to cover one per asset.
The issuer must match
ACCEPTED_ASSETSexactly.USDCfrom the wrong issuer is a different asset entirely, and a trustline to it will not make payments in the configured one succeed.
| Variable | Description | Default |
|---|---|---|
STELLAR_LISTENER_MODE |
stream (SSE + poller reconciler) or poll (interval only) |
stream |
POLL_INTERVAL_SECS |
How often the poller reconciles | 10 |
CURSOR_STALENESS_MULTIPLE |
Multiplier on POLL_INTERVAL_SECS that may elapse without a successful poll/stream event before /ready reports the detection cursor stale (503). A healthy poller cycles on the poll interval, so this only trips when the poller died or the stream wedged. |
3 |
POLL_MAX_PAGES_PER_CYCLE |
Maximum Horizon pages (200 records each) one poll cycle walks before yielding to the next tick. Bounds how long a catch-up can monopolise the poller; the cursor is checkpointed at every page boundary, so the next cycle resumes exactly where this one stopped. 0 = unlimited. |
50 |
PAYMENT_TTL_SECS |
How long an intent stays pending before expiring, from created_at |
3600 |
EXPIRY_BATCH_SIZE |
Maximum overdue intents the expiry sweeper transitions per sweep | 500 |
Intents are expired in bounded batches (EXPIRY_BATCH_SIZE) so a large
backlog drains over several sweeps instead of one long write lock — SQLite has
a single writer.
A poll cycle that fails no longer retries at the fixed POLL_INTERVAL_SECS
cadence that may have caused it. A 429/503 from Horizon backs off for at
least the Retry-After it sends; any other failure, or a rate limit with no
Retry-After, backs off exponentially with jitter (1s up to 120s), reset to
POLL_INTERVAL_SECS by the next successful cycle. Each cycle's catch-up loop
is also capped at 25 pages (5,000 records), so a backlog built up while
throttled cannot immediately re-trip the same limit — it drains over
subsequent cycles instead. See stellargate_horizon_poll_cycles_total under
Observability to track this.
First-run cursor baselining. The very first time the poller runs (no
horizon_payment_cursor persisted yet), it does not scan the gateway
account's entire payment history — that would replay everything on every
fresh deployment. Nor does it naively adopt the account's single most recent
payment as the floor: that is only safe if no payment relevant to this
gateway predates it, which fails for a reused account (a redeploy after
losing the database volume, a migration between hosts, where the account
already has payment history from before this instance ever started) and for
a startup race (a payment landing between the baselining query and the
first forward poll, which can sort at or below a single-record baseline on
read-replica lag). Neither failure produces an error — the intent just stays
pending until it expires, with no record connecting the customer's on-chain
payment to anything.
Instead, the poller pages backward (order=desc) from the tip until the
oldest record it has seen is older than every currently open (pending /
underpaid) intent's created_at — a payment cannot be relevant to an
intent it predates — deliberately over-scanning rather than under-scanning.
Re-processing an already-settled transaction is a no-op via
processed_transactions, so the cost of the overlap is a few extra Horizon
requests at boot. The walk is capped at 25 pages (5,000 records); if it hits
the cap before clearing every open intent's creation time, it baselines with
whatever overlap it found and logs a warning. When nothing is currently open,
one page of backward overlap is still taken, purely to cover the startup
race. An account with no payment history at all baselines at "0", so its
first-ever payment is captured. The chosen baseline and the number of records
skipped are logged at info on every first run.
| Variable | Description | Default |
|---|---|---|
WEBHOOK_SECRET |
HMAC-SHA256 signing secret. Must be ≥ 32 characters; known placeholder values are rejected at boot. | — |
ALLOWED_WEBHOOK_SCHEMES |
Comma-separated URL schemes accepted for webhook_url. HTTPS is enforced on public regardless of this value. |
https |
WEBHOOK_RETRY_ATTEMPTS |
Inline delivery attempts | 3 |
WEBHOOK_RETRY_DELAY_MS |
Base delay between inline retries — the first step of an exponential, jittered schedule, not a fixed interval | 5000 |
WEBHOOK_RETRY_MAX_DELAY_MS |
Ceiling on one inline retry delay. Must be ≥ WEBHOOK_RETRY_DELAY_MS. |
60000 |
WEBHOOK_TIMEOUT_SECS |
Per-attempt timeout; each retry is bounded independently | 10 |
WEBHOOK_ALLOW_PRIVATE_TARGETS |
Bypasses the SSRF private-range check. Development and tests only. | false |
Inline retries back off exponentially and are jittered. Both halves matter, and for different reasons.
Backoff, because a constant delay meant a receiver returning 503 for two
minutes produced — per delivery — three attempts at t, t+5s, t+10s. Across
a settlement burst of N payments that is 3N requests arriving in three tight
clusters, precisely when the receiver is least able to absorb them.
Jitter, because backoff alone desynchronises nothing. Deliveries that failed together share an attempt number, so a purely exponential schedule puts their next attempts at the same instant — the same lockstep, just spaced further apart.
The delay before retry n is drawn uniformly from
[ceiling/2, ceiling] where ceiling = min(WEBHOOK_RETRY_DELAY_MS × 2^(n−1), WEBHOOK_RETRY_MAX_DELAY_MS). That is equal jitter rather than the more
common full jitter over [0, ceiling]: full jitter can return a near-zero
delay, and this service already rejects WEBHOOK_RETRY_DELAY_MS=0 at boot
because a zero delay causes exactly the retry bursts being avoided here. Equal
jitter keeps a guaranteed floor under every retry while still spreading a
co-failing batch across half the window.
WEBHOOK_REDRIVE_GRACE_SECS is validated at boot against this schedule, so a
grace window too short to clear the worst-case inline delivery — which would let
the redrive worker send a delivery whose inline dispatch is still running — is
rejected rather than discovered in production.
Recovers deliveries left pending/failed by a process that exited mid-send or a receiver that was down when inline retries ran out. Its first pass runs immediately at startup, so a restart redrives without waiting a full interval.
| Variable | Description | Default |
|---|---|---|
WEBHOOK_REDRIVE_INTERVAL_SECS |
Scan frequency | 30 |
WEBHOOK_REDRIVE_CONCURRENCY |
Max redrive requests in flight | 4 |
WEBHOOK_REDRIVE_MAX_ATTEMPTS |
Total attempts (inline + redrive) before a delivery is left permanently failed |
8 |
WEBHOOK_REDRIVE_GRACE_SECS |
Idle time required before the worker touches a row, so it never races an in-flight inline delivery. Also the floor under the backoff. Must be 1..=86,400. |
60 |
WEBHOOK_REDRIVE_BACKOFF_INITIAL_SECS |
Exponential backoff base: initial × 2^(attempts−1). A row never attempted is exempt and gated only by the grace window. Must be 0..=86,400; 0 disables growth. |
30 |
WEBHOOK_REDRIVE_BACKOFF_MAX_SECS |
Backoff ceiling. Must be 1..=86,400 and ≥ the initial value. |
900 |
WEBHOOK_REDRIVE_JITTER_SECS |
Random extra delay (0–N seconds, drawn per row) on top of the window above. Must be 0..=86,400; 0 disables. |
30 |
The jitter is what actually decorrelates a batch. Rows that failed together
share an attempts value and a near-identical last_attempt, so
initial × 2^(attempts−1) resolves to the same instant for all of them and the
worker — which computes eligibility in SQL — would hand itself the whole cluster
on every pass. The offset is drawn per row per statement, so each pass admits a
different random subset and the batch spreads over several intervals. It only
ever delays a row, never pulls one forward past the grace window.
Two tables grow with traffic and have no natural bound — idempotency_keys
gains a row per guarded create, webhook_deliveries one per delivery attempt.
A background worker prunes both. On the single-volume deployments this service
targets, an unbounded table eventually fills the disk and takes the gateway
down.
| Variable | Description | Default |
|---|---|---|
RETENTION_INTERVAL_SECS |
How often the worker prunes | 3600 |
WEBHOOK_DELIVERY_RETENTION_DAYS |
Days to keep terminal (delivered/failed) delivery rows. 0 keeps them forever. |
30 |
IDEMPOTENCY_RETENTION_DAYS |
Days to keep idempotency keys — they only need to outlive the window in which a client might retry. 0 keeps them forever. |
7 |
A
pendingdelivery is never pruned regardless of age: the redrive worker still owns it, and deleting it would silently drop a webhook the merchant is owed. The worker marks rowsfailedonce attempts are exhausted, so nothing stays exempt forever.
Deletes run in batches of 500 with a per-cycle cap. SQLite has a single writer,
so one unbounded DELETE over a large table would stall every payment write
until it finished; a backlog drains over several cycles instead.
| Variable | Description | Default |
|---|---|---|
ADMIN_PROVISIONING_SECRET |
Required via X-Admin-Secret to call POST /merchants. Unset disables provisioning entirely (always 401). |
(unset — disabled) |
CORS_ALLOWED_ORIGINS |
Comma-separated origins. Required on public; omitting on testnet falls back to permissive with a warning. |
(unset) |
RATE_LIMIT_REQUESTS_PER_SEC |
Base per-IP limit. Write routes get this rate; read-only routes get 5×. Must be > 0 — boot fails otherwise; there is no "disabled" value. |
10 |
TRUSTED_PROXY_CIDRS |
Comma-separated CIDR blocks whose X-Forwarded-For/X-Real-IP headers are honored for rate-limit bucketing and auth-log attribution. Every other peer is attributed by its own address and its headers are ignored — the safe default. |
(unset — headers ignored) |
DB_POOL_MAX_CONNECTIONS |
SQLite pool size. WAL allows one writer plus many readers. | 10 |
DB_BUSY_TIMEOUT_MS |
Lock-acquisition wait before erroring. Must be > 0 under concurrent load. |
5000 |
Every request is assigned to a bucket, and each bucket is limited
independently per client IP — so provisioning a merchant can never eat into a
client's payment quota, or vice versa. The client IP is resolved per
TRUSTED_PROXY_CIDRS above.
| Bucket | Routes | Quota |
|---|---|---|
payments |
POST /payments |
RATE_LIMIT_REQUESTS_PER_SEC × 1 |
merchants |
POST /merchants |
RATE_LIMIT_REQUESTS_PER_SEC × 1 |
redeliver |
POST /payments/:id/webhooks/:delivery_id/redeliver |
RATE_LIMIT_REQUESTS_PER_SEC × 1 |
default |
everything else, including all other GET routes |
RATE_LIMIT_REQUESTS_PER_SEC × 5 |
/health, /ready and /metrics are not in any bucket — they are exempt
from rate limiting entirely, not merely given a generous one. They are cheap,
come from a trusted orchestrator rather than the public internet, and their
whole purpose is to stay answerable when the system is under stress; sharing a
bucket with merchant traffic would mean a load spike that trips the limiter
could also turn an orchestrator's own liveness probe into a 429 — see
DEPLOYMENT.md for the failure mode this avoids.
Write and sensitive routes get the base rate; read-only traffic gets a more generous allowance so ordinary polling is not throttled. Redelivery is bucketed by shape, not by path — the URL carries payment and delivery ids, and keying on those would let every id mint its own limiter entry, which is both an unbounded map and a trivially bypassed limit.
Every response carries the current state of its bucket, so a client can pace itself before being throttled rather than discovering the limit by hitting it:
| Header | Meaning |
|---|---|
X-RateLimit-Limit |
The bucket's effective quota, i.e. the multiplier is already applied |
X-RateLimit-Remaining |
Requests still available in this bucket right now |
X-RateLimit-Reset |
Delta-seconds until the bucket is back to full capacity |
A 429 additionally carries Retry-After, in delta-seconds, derived from the
limiter's own state — governor knows exactly when the next request would be
permitted, and this is that value rounded up with a floor of 1. Retry-After
is time until a single request is permitted; X-RateLimit-Reset is time
until the bucket is full, so Reset is never the smaller of the two.
Reset is a delta rather than an epoch timestamp so that a client with a skewed
clock still gets a usable answer.
All four headers are listed in Access-Control-Expose-Headers. The CORS spec
hides every response header outside its safelist unless it is named there, and
Retry-After is not on that safelist — a self-pacing contract a browser cannot
read would be the same as no contract at all.
Note on precision. Quotas are per-second, so a single cell replenishes in
1 / RATE_LIMIT_REQUESTS_PER_SECseconds — always under a second. SinceRetry-Afteris an integer number of seconds (RFC 9110), it currently reports1at every configured rate. It is derived rather than hard-coded so that it stays correct if the quota shape changes; for pacing now, useX-RateLimit-Remaining.
The API is versioned by path prefix. /v1 is canonical — use it for new
integrations:
POST /v1/payments
GET /v1/payments/:id
POST /v1/merchants
The bundled dashboard uses this same canonical prefix through one shared API
base, so all of its authenticated requests track the current API version.
Operational requests such as / and /ready remain unversioned as described
below.
Unversioned paths (/payments, /merchants) still work and serve the same
data, so nothing breaks today. They respond with headers pointing at their
replacement:
Deprecation: true
Link: </v1/payments>; rel="successor-version"
Operational endpoints — /health, /ready, /metrics, /dashboard and / —
are not versioned. They are infrastructure rather than contract; moving a
liveness probe with every API revision would break probes and scrape configs
for no benefit.
The full request/response contract — the Idempotency-Key request header and
the Deprecation, Link and x-request-id response headers included — is
described in openapi.yaml. CI lints that spec and
cross-checks its documented paths against the live router, so it cannot quietly
drift from what the service actually serves.
| Change | How it ships |
|---|---|
| Adding a field, endpoint, or optional parameter | Within the current version. Treat unknown fields as ignorable. |
Changing or removing a field, changing a status code or error code |
A new version prefix (/v2) |
| Security fixes that must apply to existing callers | Within the current version, documented in CHANGELOG.md as breaking |
That last row is a deliberate exception rather than an oversight: a data-exposure fix that only applied to callers who opted into a new version would leave the exposure in place for everyone who did not.
When a version is retired it will carry a Sunset header (RFC 8594) with the
removal date, announced in the changelog and release notes first. No Sunset
date is currently set for the unversioned paths — they emit Deprecation
only, because a sunset header is a commitment and none has been made.
| Scheme | Header | Used by |
|---|---|---|
| Merchant API key | Authorization: Bearer <api_key> |
POST /payments, GET /payments, webhook delivery routes |
| Admin secret | X-Admin-Secret: <secret> |
POST /merchants |
GET /payments/:id is reachable without a key so a checkout page can poll it directly, but what it returns depends on who is asking — an unauthenticated caller gets a minimal projection with no merchant or financial detail. See the endpoint below.
Both schemes are declared in openapi.yaml as bearerAuth and
adminSecret, and each operation carries its own security requirement, so a
generated SDK exposes a way to supply credentials and sends them without manual
modification. GET /payments/:id declares [{}, {bearerAuth: []}] — the
OpenAPI spelling of "auth is optional but changes the response".
Every error response uses the same shape:
{
"error": "A human-readable explanation",
"code": "stable_machine_readable_code"
}The code field is stable across releases and is what you should branch on.
Request bodies are closed. Every JSON body is validated against exactly the
fields its endpoint accepts, and anything else is rejected with 400
unknown_field naming the offending field. A typo is not quietly dropped:
curl -X POST http://localhost:3000/v1/payments \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"amount": "100", "assset": "USDC"}'{
"error": "invalid request body: Failed to deserialize the JSON body into the target type: unknown field `assset`, expected one of `amount`, `asset`, `webhook_url`",
"code": "unknown_field"
}This matters most where a field has a default. asset defaults to XLM when
absent, so before this the request above created a 100 XLM intent and
returned 201 describing it — the single transposed character was recoverable
only by reading the response back carefully. merchant_id was the other sharp
edge: earlier revisions of openapi.yaml advertised it, but the handler has
always taken the merchant from the API key, so a client that sent it believed
it was choosing the tenant and was not.
Query strings are closed too. The listing endpoints accept exactly the
parameters documented for them, and anything else is rejected with 400
unknown_parameter naming the offending key:
curl "http://localhost:3000/v1/payments?stauts=completed" \
-H "Authorization: Bearer $API_KEY"{
"error": "invalid query string: Failed to deserialize query string: unknown field `stauts`, expected one of `status`, `limit`, `offset`, `cursor`, `include_total`",
"code": "unknown_parameter"
}The cost of the old behaviour was the same as for bodies, and quieter: a
discarded filter is an unfiltered page returned as 200 OK, so
?stauts=completed listed every payment including pending ones and a
reconciliation script that filtered server-side read unpaid intents as paid.
?page and ?per_page are the other common shapes of this — plausible
pagination names that this API has never used. Closing the parameter set also
keeps it evolvable: while unknown parameters were ignored, adding a real page
later would silently change the behaviour of requests that appeared to work.
| Code | HTTP | Meaning |
|---|---|---|
unauthorized |
401 |
Missing/invalid API key or admin secret |
invalid_request |
400 |
Malformed JSON or a deserialization failure |
unknown_field |
400 |
Request body contained a field the endpoint does not accept |
unknown_parameter |
400 |
Query string contained a parameter the endpoint does not accept |
invalid_query |
400 |
Query string could not be deserialized (e.g. a non-numeric limit) |
unsupported_media_type |
415 |
Content-Type is not application/json |
payload_too_large |
413 |
Request body exceeds the configured maximum size (MAX_BODY_BYTES) |
unsupported_asset |
400 |
Asset is not in ACCEPTED_ASSETS |
invalid_amount |
400 |
Not a positive decimal with ≤ 7 decimal places |
invalid_webhook_url |
400 |
Malformed, disallowed scheme, over 2048 chars, or SSRF-rejected |
invalid_status |
400 |
status filter is not a recognized value |
invalid_cursor |
400 |
cursor could not be decoded |
conflicting_pagination |
400 |
cursor and offset were sent together |
invalid_limit |
400 |
limit is outside 1..=PAGINATION_MAX_LIMIT |
payment_not_found |
404 |
No such payment, or it belongs to another merchant |
merchant_not_found |
404 |
No merchant with that id |
key_not_found |
404 |
No active key with that id for this merchant |
last_active_key |
400 |
Refused: would revoke a merchant's only usable key |
invalid_label |
400 |
Key label exceeds 100 characters |
delivery_not_found |
404 |
No such delivery for that payment |
webhook_target_blocked |
400 |
Redelivery target rejected by the SSRF guard |
already_delivered |
409 |
Delivery was already successfully delivered; pass ?force=true to redeliver anyway |
webhook_delivery_failed |
502 |
Receiver returned a non-success response |
rate_limit_exceeded |
429 |
Per-IP bucket limit exceeded |
idempotency_conflict |
409 |
Concurrent creates raced on one idempotency key; retry |
not_found |
404 |
No matching route |
method_not_allowed |
405 |
Path exists, but not for this HTTP method |
request_timeout |
408 |
Request exceeded REQUEST_TIMEOUT_SECS |
internal_error |
500 |
Unexpected server-side failure |
405, 413, and 408 are otherwise generated by axum and tower_http
themselves — before this, they carried an empty body rather than the envelope
above, so a client parsing the documented contract got a JSON decode failure
instead of an error it could act on. A response-rewriting middleware
(json_error_envelope_middleware in src/api/mod.rs) fills in the envelope
for these three cases wherever nothing downstream has already answered with
one.
Provision a merchant and return its API key. Admin only — requires X-Admin-Secret. There is no self-service signup; this is meant to be run by whoever operates the gateway.
curl -X POST http://localhost:3000/merchants \
-H "X-Admin-Secret: $ADMIN_PROVISIONING_SECRET"201 Created
{
"merchant_id": "a1b2c3d4-...",
"api_key": "sg_ec5759103e27f...",
"key_id": "d15f5a1a-..."
}
⚠️ api_keyis returned once, in plaintext, and is never recoverable. Only a hash is stored. Save it immediately.
Keys are 256-bit tokens from the OS CSPRNG, prefixed sg_ so they are
recognisable in logs and matchable by secret scanners. Use key_id to revoke
this key later.
Issue an additional key for a merchant — this is how rotation works. Admin only.
curl -X POST http://localhost:3000/merchants/$MERCHANT_ID/keys \
-H "X-Admin-Secret: $ADMIN_PROVISIONING_SECRET" \
-H "Content-Type: application/json" \
-d '{"label": "rotation-2026-08"}'201 Created — { "key_id": "…", "api_key": "sg_…", "prefix": "sg_6c85bd46e", "label": "rotation-2026-08" }
Rotation is issue-then-revoke, not replace-in-place. The new key is live immediately while the old one keeps working, so a merchant can deploy the new credential and only then retire the old one — there is never a window with no valid key.
labelis optional, purely for your own bookkeeping.
List a merchant's keys, including revoked ones so the history stays visible.
Admin only. Keyset (cursor) paginated with the same conventions as
GET /payments — revoked keys are never deleted, so a
merchant that rotates regularly accumulates rows without bound, and this
endpoint used to return every one of them in a single unbounded response
(issue #262).
| Param | Default | Notes |
|---|---|---|
limit |
20 (max 100) | Page size. Rejected with 400 invalid_limit outside 1..=100, not silently clamped. |
cursor |
(unset) | Opaque cursor from a previous response's next_cursor. Omit for the first page. |
active |
(unset — both) | true returns only usable keys, false only revoked ones. Omitted returns both. |
curl "http://localhost:3000/merchants/$MERCHANT_ID/keys?limit=20&active=true" \
-H "X-Admin-Secret: $ADMIN_PROVISIONING_SECRET"200 OK
{
"merchant_id": "abee3b99-…",
"keys": [
{
"key_id": "e150ccc8-…",
"prefix": "sg_6c85bd46e",
"label": "rotation-2026-08",
"created_at": "2026-08-11T15:45:29Z",
"last_used_at": "2026-08-11T15:45:29Z",
"revoked_at": null,
"active": true
}
],
"limit": 20,
"next_cursor": null
}Metadata only — the secret is unrecoverable by design, so this endpoint cannot
leak a usable credential. prefix exists so you can tell keys apart when
deciding which to revoke. last_used_at is refreshed at most once a minute per
key: it runs on every authenticated request, and SQLite takes a write lock per
update, so touching it unconditionally would put a write in the path of every
read.
A full page (keys.len() == limit) mints next_cursor from its last row; pass
it back as cursor to fetch the next page. A short page (or an empty one)
returns next_cursor: null, meaning there is nothing more. active=true lets
an operator looking for currently-usable keys skip straight past the revoked
history instead of paging through it to find them.
Revoke a key. Effective immediately — the next request using it gets 401.
Admin only.
200 OK — { "key_id": "…", "revoked": true }
Refuses with
last_active_keyif it would revoke a merchant's only active key. This API has no self-service recovery, so that would turn a routine revocation into an incident. Issue a replacement first.
Revocation is a tombstone, not a delete, so the audit trail survives it. Keys are scoped to their merchant: one merchant's key id cannot be revoked through another's path.
Create a payment intent. Requires a merchant API key; the merchant is taken from the key, not the request body.
Request
{
"amount": "10.00",
"asset": "XLM",
"webhook_url": "https://yourapp.com/webhooks/stellar"
}| Field | Type | Required | Constraints |
|---|---|---|---|
amount |
string | ✅ | Positive decimal, ≤ 7 decimal places |
asset |
string | ❌ | Must be in ACCEPTED_ASSETS. Defaults to XLM. The issuer configured for that code is resolved at creation time and returned as asset_issuer; it is not accepted in the request. |
webhook_url |
string | ❌ | ≤ 2048 chars; scheme must be allowed; HTTPS required on public; SSRF-checked |
Any other field is rejected with 400 unknown_field — see Error
Envelope. In particular there is no merchant_id field: the
merchant comes from the API key and cannot be overridden by the body.
| Header | Required | Description |
|---|---|---|
Content-Type: application/json |
✅ | Anything else returns 415 |
Authorization: Bearer <key> |
✅ | Merchant API key |
Idempotency-Key |
❌ | Client-chosen key, scoped per merchant. Reuse returns the original intent with 200 OK instead of creating a duplicate. |
201 Created (or 200 OK on an idempotency-key hit)
{
"id": "a1b2c3d4-...",
"destination_address": "GBBD47IF6LWK7P7...",
"memo": "A1B2C3D4",
"amount": "10",
"asset": "XLM",
"asset_issuer": null,
"status": "pending",
"created_at": "2026-04-29T15:00:00Z",
"expires_at": "2026-04-29T16:00:00Z"
}The payer must send exactly
amountofassettodestination_addresswithmemoset as a text memo. The intent expires atexpires_atif unpaid.
Fetch a payment's current state. Reachable with or without a key, but the response differs.
Without a credential — a minimal projection, enough to poll for completion:
{
"id": "a1b2c3d4-...",
"status": "pending",
"expires_at": "2026-04-29T16:00:00Z"
}With the owning merchant's key — the full record:
curl http://localhost:3000/payments/$ID -H "Authorization: Bearer $API_KEY"{
"id": "a1b2c3d4-...",
"merchant_id": "your-merchant-id",
"destination_address": "GBBD47IF6LWK7P7...",
"memo": "A1B2C3D4",
"amount": "10",
"asset": "XLM",
"asset_issuer": null,
"status": "pending",
"tx_hash": null,
"paid_amount": null,
"created_at": "2026-04-29T15:00:00Z",
"updated_at": "2026-04-29T15:00:00Z",
"expires_at": "2026-04-29T16:00:00Z"
}| Caller | Response |
|---|---|
No Authorization header |
200 — minimal projection above |
| Owning merchant's key | 200 — full record |
| Another merchant's key | 404 payment_not_found |
| Invalid or revoked key | 401 unauthorized |
Another merchant's key gets a
404, identical to an id that does not exist. A403would confirm the payment is real and belongs to someone else, which is precisely the cross-tenant signal this is meant to withhold.An invalid key is an error rather than a silent fall back to the public view, so a typo'd or revoked credential says so instead of looking like missing fields.
The public projection omits merchant_id, every amount, tx_hash and the
destination address by design. Payment ids travel through logs, referrers and
browser history, so treat anything on that response as effectively public.
Status values
| Status | Meaning |
|---|---|
pending |
Awaiting payment |
completed |
Fully paid (includes overpayment) |
underpaid |
Partially paid; still watched for a top-up |
expired |
TTL elapsed before payment arrived; no longer watched |
List the authenticated merchant's payments, newest first. Supports cursor (recommended) and offset (legacy) pagination.
| Param | Description | Default |
|---|---|---|
status |
Filter by pending, completed, underpaid, or expired |
all |
limit |
Page size, 1–100. Outside that range: 400 invalid_limit. |
20 |
cursor |
Keyset cursor from a previous next_cursor. Mutually exclusive with offset. |
— |
offset |
Rows to skip (legacy; prefer cursor). Capped at 10000 — above that, 400 invalid_offset. Mutually exclusive with cursor. |
0 |
include_total |
Offset mode only. Compute and return total. |
false |
200 OK — cursor mode (cursor set)
{
"payments": [ { "id": "...", "status": "pending" } ],
"limit": 20,
"next_cursor": "3230..."
}200 OK — offset mode (no cursor parameter; offset set, or neither set)
{
"payments": [ { "id": "...", "status": "pending" } ],
"limit": 20,
"offset": 0,
"next_cursor": "3230..."
}Both modes order rows identically (created_at DESC, then id DESC to break
the whole-second created_at ties), so a next_cursor returned by an offset
page resumes cleanly in cursor mode. next_cursor is null on the final page
of either mode. Offset mode additionally returns offset.
Pick one mode per request. Sending cursor and offset together is 400 conflicting_pagination. The two modes answer from different queries and return
different bodies, so there is no reading of "both" that is not a guess: the
request would be served from the cursor, offset silently discarded, and the
offset field absent from the response. That matters most during the very
migration the next_cursor above exists to encourage, which is when a client is
most likely to still be sending its old offset. Presence is what counts, not
the value, so ?cursor=...&offset=0 is rejected too. Drop offset from the
request once you start passing cursor.
totalis opt-in (?include_total=true), not sent by default. SQLite has no cached row count, so computingtotalis a fullCOUNT(*)scan over every matching row — on every list request, including the first page, regardless of how deep into the results the caller actually looks. Most clients render "next page" affordances fromnext_cursoralone and never readtotal, so the default path no longer pays for it. Ask for it explicitly when you need it:{ "payments": [ { "id": "...", "status": "pending" } ], "total": 42, "limit": 20, "offset": 0, "next_cursor": "3230..." }
totalis entirely absent from the response (notnull) when not requested, so a client can tell "not computed" apart from "computed as zero." Cursor mode has never returnedtotalandinclude_totalhas no effect there.
Migration path. Switch to cursor pagination by sending
cursorinstead ofoffset. Start with a first request that carries nocursorand nooffset, then continue with the previous response'snext_cursoron each subsequent request. Thenext_cursoran offset response returns is a courtesy for this migration — you may use it as the first cursor, but once you do you must stay in cursor mode; offset and cursor pagination cannot be mixed within one scan. Offset mode is retained for backward compatibility and is deprecated: like any offset paging, it can skip or repeat rows if data changes mid-scan.
offsetis capped at10000. SQLite implementsOFFSETby producing and discarding every skipped row, so cost grows with depth; a request past the cap returns400 invalid_offsetinstead of paying for the scan. Cursor pagination has no such limit — it staysO(log n)regardless of depth — so this is another reason to prefer it over deep offset paging.
List delivery attempts for a payment, newest first. Requires the owning merchant's API key.
| Query param | Description | Default |
|---|---|---|
status |
Filter by delivery status: pending, delivered, or failed |
— |
limit |
Page size, 1–100. Outside that range: 400 invalid_limit. |
20 |
cursor |
Keyset cursor from a previous next_cursor |
— |
Any other query parameter is rejected with 400 unknown_parameter — see
Error Envelope.
next_cursor is null on the final page. To page through the history, start with a
request that carries no cursor, then pass the previous response's next_cursor
on each subsequent request.
200 OK
{
"payment_id": "a1b2c3d4-...",
"deliveries": [
{
"id": "d1e2f3...",
"url": "https://yourapp.com/webhooks/stellar",
"event": "payment.completed",
"status": "delivered",
"attempts": 1,
"last_attempt": "2026-04-29T15:04:00Z",
"created_at": "2026-04-29T15:03:59Z"
}
],
"limit": 20,
"next_cursor": "3230..."
}Manually re-send a delivery. The stored payload and event type are replayed verbatim with a fresh timestamp and signature. The SSRF guard re-runs against the target.
The dead-letter view: every delivery for the authenticated merchant, across
all of their payments. Defaults to status=failed.
This is the endpoint to reach for when a merchant says they are missing events,
because that question arrives without a payment id —
GET /payments/:id/webhooks can only answer it if you already know where to
look.
| Query | Default | Notes |
|---|---|---|
status |
failed |
One of failed, pending, delivered |
limit |
20 |
1–100; outside that range: 400 invalid_limit |
cursor |
— | Opaque keyset cursor, same convention as GET /payments |
Any other query parameter is rejected with 400 unknown_parameter. It matters
most here, where status defaults to failed: a typo'd filter would otherwise
answer with the dead-letter list and look entirely plausible.
curl "http://localhost:3000/v1/payments/webhooks?status=failed&limit=50" \
-H "Authorization: Bearer $API_KEY"200 OK
{
"deliveries": [
{
"id": "d1e2f3...",
"payment_id": "a1b2c3d4-...",
"url": "https://yourapp.com/webhooks/stellar",
"event": "payment.completed",
"status": "failed",
"attempts": 8,
"last_attempt": "2026-04-29T15:04:00Z",
"acknowledged_at": null,
"created_at": "2026-04-29T15:03:59Z"
}
],
"status": "failed",
"limit": 50,
"next_cursor": "3230..."
}Scoping is a join to payments, not a filter you supply, so this can never
return another merchant's deliveries. The signed payload is omitted — a
listing is for triage, not replay.
Bulk recovery after you have fixed your receiver. Requeues failed deliveries so the background redrive worker retries them.
# Everything that failed
curl -X POST http://localhost:3000/v1/payments/webhooks/redeliver \
-H "Authorization: Bearer $API_KEY"
# Or just specific ones (max 100 per request)
curl -X POST http://localhost:3000/v1/payments/webhooks/redeliver \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"delivery_ids": ["d1e2f3...", "d4e5f6..."]}'200 OK — { "requeued": 42, "detail": "..." }
This endpoint sends nothing itself. It resets matching rows to
pendingwithattempts = 0and hands them to the redrive worker, whoseWEBHOOK_REDRIVE_CONCURRENCYand exponential backoff already bound the outbound rate. Requeueing ten thousand deliveries therefore costs oneUPDATEand cannot stampede a receiver that has only just come back up.
Requeueing also acknowledges the delivery — see below.
A terminal failure is the evidence for "we never received your webhook", and
that question usually arrives long after the fact. So a failed delivery that
nobody has acknowledged is exempt from
WEBHOOK_DELIVERY_RETENTION_DAYS and is not deleted on a timer.
To keep that from trading one unbounded table for another, a retained failure is
compacted once it ages past the window: the row survives, its stored
payload is cleared. The payload's only consumer is redelivery, which is not
something anyone does to a months-old failure, and it is by far the largest
column — so the record of what was lost stays queryable indefinitely at a few
hundred bytes.
Requeueing via the endpoint above sets acknowledged_at, which returns the row
to ordinary retention.
Terminal failures are also counted in
stellargate_webhook_deliveries_total{outcome="failed"} — including the
SSRF-blocked path, which previously incremented nothing, leaving a whole class
of permanent failure invisible to alerts.
Liveness probe — cheap, and fails only on conditions a restart would fix. Returns 200 OK while the process is running and every expected background task (poller, stream, sweeper, retention, redrive) is running. A task that died — a panic, or a poller that exited at startup — returns 503 naming the dead task, so a process whose payment detection is gone never looks healthy forever.
{
"status": "ok",
"tasks": { "expected": 5, "live": 5, "disabled": [] }
}{
"status": "unavailable",
"reason": "background task(s) not running: poller",
"tasks": { "expected": 5, "live": 4, "disabled": [] }
}tasks answers "how many workers should be running, and how many are?" — a
question the process could not previously answer at all. expected already
excludes workers that configuration has deliberately switched off, so a
poll-only deployment (no stream listener) or one with both retention windows set
to 0 does not read as permanently degraded:
{
"status": "ok",
"tasks": {
"expected": 4,
"live": 4,
"disabled": [
{ "task": "retention", "reason": "both WEBHOOK_DELIVERY_RETENTION_DAYS and IDEMPOTENCY_RETENTION_DAYS are 0" }
]
}
}Each worker returns an explicit reason rather than leaving the supervisor to infer one, and the three are handled differently:
| Exit | Restarted? | Logged at | /health |
|---|---|---|---|
| Shutdown requested | no | info |
n/a — the process is going away |
| Disabled by configuration | no — terminal, reported once at boot | info |
listed under disabled; not a failure |
| Fatal error | yes, with bounded backoff | error, naming the task |
counts as not running |
The distinction is load-bearing. Retention exiting because both windows are 0
is a deployment choice; the stream listener exiting because its HTTP client
would not build is a fault that silently ends stream-based payment detection.
Both used to be recorded identically as "stopped", so the counters could not
separate them and neither could anyone reading them.
Readiness probe. Runs SELECT 1 against the database, probes Horizon (3 s timeout), and — once a gateway is configured — requires the payment-detection cursor to have advanced recently. The cursor is fresh when a successful poll or stream event landed within POLL_INTERVAL_SECS × CURSOR_STALENESS_MULTIPLE; a dead poller with a reachable Horizon is therefore 503, not green.
200 OK — { "status": "ok" }
503 Unavailable — { "status": "unavailable", "reason": "database unreachable | Horizon unreachable: … | payment detection stalled: …" }
Prometheus exposition format. See Observability.
The operator dashboard. Also serves /dashboard/app.css and
/dashboard/app.js. See Dashboard.
Every on-chain payment matched by memo, destination, and asset resolves as follows:
| Scenario | status |
Event | delta |
|---|---|---|---|
| Paid exactly | completed |
payment.completed |
— |
| Paid more than requested | completed |
payment.overpaid |
excess to refund |
| Paid less than requested | underpaid |
payment.underpaid |
shortfall owed |
| Top-up reaching exactly the total | completed |
payment.completed |
— |
| Top-up exceeding the total | completed |
payment.overpaid |
cumulative excess |
| TTL elapsed, unpaid | expired |
payment.expired |
— |
Payment after completed or expired |
unchanged | payment.unexpected |
unexpected amount to refund |
Overpayment fulfils the intent. The delta field carries the excess; refunding it is the merchant's responsibility — the gateway cannot send funds.
Underpayment leaves the intent open and watched. When a follow-up payment to the same memo brings the cumulative total to or above the requested amount, the intent completes.
Limitations to be aware of:
- Only a single top-up is tracked per underpaid intent. If more is needed, the payer should send the full remaining
deltain one transaction. - Once an intent is
completed, further payments to the same address and memo fire apayment.unexpectedwebhook so the merchant can refund them, but the intent'scompletedstatus is not changed.
When a payment reaches a terminal state, StellarGate POSTs a signed JSON event to the intent's webhook_url.
| Event | Fired when |
|---|---|
payment.completed |
Cumulative received equals the requested amount |
payment.overpaid |
Cumulative received exceeds it (delta = excess, full detail only) |
payment.underpaid |
Payment received but short (delta = shortfall, full detail only) |
payment.expired |
TTL elapsed with no payment |
payment.unexpected |
Payment received after intent is already completed or expired (delta = the unexpected amount the merchant must refund) |
WEBHOOK_PAYLOAD_DETAIL controls how much the body carries. HMAC signing
(below) proves the body is authentic; it says nothing about who else can read
it in transit, and on any network other than public,
ALLOWED_WEBHOOK_SCHEMES may permit plain http — see SECURITY.md
for the full exposure model.
minimal (the default) — enough to know something happened and look it
up; no tenant or financial detail:
{
"event": "payment.overpaid",
"payment_id": "a1b2c3d4-...",
"status": "completed",
"updated_at": "2026-01-01T00:00:01Z"
}full — the previous rich payload, opt in with WEBHOOK_PAYLOAD_DETAIL=full:
{
"event": "payment.overpaid",
"payment_id": "a1b2c3d4-...",
"status": "completed",
"updated_at": "2026-01-01T00:00:01Z",
"merchant_id": "your-merchant-id",
"tx_hash": "abc123...",
"amount": "10",
"paid_amount": "12.5",
"asset": "XLM",
"asset_issuer": null,
"delta": "2.5"
}delta is present only on payment.overpaid and payment.underpaid, and
only under full detail. A receiver that needs the fields minimal omits
already holds an API key and can call GET /v1/payments/:id for the full
record instead.
Migrating from the previous default. Every field prior versions sent is still available — set
WEBHOOK_PAYLOAD_DETAIL=fullto keep receiving exactly the payload shown above. There is no forced cutover:fullis supported indefinitely, not a deprecated compatibility mode.
| Header | Value |
|---|---|
X-StellarGate-Timestamp |
Unix seconds at signing time |
X-StellarGate-Signature |
Hex HMAC-SHA256 of "{timestamp}.{raw_body}", keyed with WEBHOOK_SECRET |
X-StellarGate-Event |
Convenience copy of the event type — not signed |
Binding the signature to the timestamp (Stripe-style) is what prevents indefinite replay of a captured request.
- Read the timestamp (
t) and signature (sig). - Reject if
abs(now − t) > tolerance. 5 minutes is recommended. - Concatenate
"{t}.{raw_body}"using the exact received bytes — verify before any JSON re-encoding, which would change them. - Compute
HMAC_SHA256(WEBHOOK_SECRET, "{t}.{raw_body}"), hex-encoded. - Compare against
sigin constant time. - Only after the signature passes, read
eventfrom the body.
⚠️ Never route security-sensitive logic onX-StellarGate-Event. It is outside the signed material and can be altered in transit without invalidating the signature. Theeventfield inside the verified body is authoritative.
Node.js
const crypto = require("crypto");
function verify(rawBody, headers, secret, toleranceSec = 300) {
const t = Number(headers["x-stellargate-timestamp"]);
const sig = headers["x-stellargate-signature"];
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) {
return false; // stale or missing timestamp
}
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(sig, "hex");
const b = Buffer.from(expected, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
function handleWebhook(rawBody, headers, secret) {
if (!verify(rawBody, headers, secret)) throw new Error("invalid signature");
const { event } = JSON.parse(rawBody); // ← authenticated; safe to route on
switch (event) {
case "payment.completed": /* fulfil the order */ break;
case "payment.overpaid": /* fulfil, then refund `delta` */ break;
case "payment.underpaid": /* await top-up of `delta` */ break;
case "payment.expired": /* release the cart */ break;
}
}Python
import hmac, hashlib, time
def verify(raw_body: bytes, headers, secret: str, tolerance: int = 300) -> bool:
try:
t = int(headers["X-StellarGate-Timestamp"])
except (KeyError, ValueError):
return False
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(
secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, headers.get("X-StellarGate-Signature", ""))Delivery is at-least-once. A receiver may see the same event more than once — after an inline retry, a redrive, or a manual redelivery — so handlers must be idempotent. Key on payment_id plus event.
Every attempt is recorded in webhook_deliveries and inspectable via GET /payments/:id/webhooks. A delivery that exhausts WEBHOOK_REDRIVE_MAX_ATTEMPTS is left failed and can still be redelivered manually.
For the full canonical reference, see WEBHOOK_REFERENCE.md.
No custody. The gateway never holds a secret key, never signs, and never submits a transaction. Compromising it does not move funds — it only watches an address.
Reads are scoped to the owning merchant. GET /payments/:id returns full detail only to the merchant that owns it; unauthenticated callers get a status-only projection with no merchant id or amounts, and another merchant's key gets a 404 rather than a 403 so the response cannot confirm a payment exists.
API keys are hashed at rest. They are 256-bit tokens from the OS CSPRNG, shown once at issue and never stored in plaintext. Each merchant can hold several, so a key can be rotated without downtime, and any key can be revoked instantly — revocation takes effect on the next request. Revoking a merchant's last active key is refused, since this API has no self-service recovery.
SSRF protection on webhook targets. A webhook_url has its host resolved and rejected if it lands on loopback, link-local (including the cloud metadata address 169.254.169.254), private, or otherwise reserved ranges. The check runs again on every dispatch and redelivery against the exact resolved address rather than a fresh DNS lookup, closing the DNS-rebinding window.
HTTPS enforced on mainnet. On STELLAR_NETWORK=public, a webhook_url must be HTTPS regardless of ALLOWED_WEBHOOK_SCHEMES — a permissive scheme list cannot downgrade mainnet delivery to plaintext.
Rate limiting. Every route falls into a per-IP bucket. Write and sensitive routes get the base quota; read-only routes get 5×. The limiter cache is capacity-bounded with idle eviction, so key cardinality cannot exhaust memory.
Client IP attribution is fail-closed. X-Forwarded-For/X-Real-IP are client-supplied, so they are honored only when the socket peer is a configured trusted proxy (TRUSTED_PROXY_CIDRS) — an unset allow-list means the headers are always ignored and the peer address is used, so a caller can't rotate a header to evade the limiter or poison the auth logs. When no peer address is available at all, every request shares a single key rather than trusting a header.
Bounded requests. Bodies are capped at MAX_BODY_BYTES (256 KiB by default) and every request is subject to REQUEST_TIMEOUT_SECS.
Fail-fast configuration. Invalid strkeys, unknown listener modes, and short webhook secrets abort startup instead of degrading silently.
To report a vulnerability, see SECURITY.md.
GET /metrics exposes Prometheus metrics:
| Metric | Type | Description |
|---|---|---|
stellargate_auth_attempts_total |
counter | Labelled by outcome, and reason on failure (missing_key, invalid_key) |
stellargate_webhook_deliveries_total |
counter | Delivery outcomes |
stellargate_webhook_retries_total |
counter | Retry attempts |
stellargate_webhook_delivery_latency_ms |
histogram | End-to-end delivery latency |
stellargate_tasks_started_total |
counter | Background task starts (including restarts) |
stellargate_tasks_stopped_total |
counter | Clean background task stops |
stellargate_tasks_failed_total |
counter | Background task panics |
stellargate_task_restarts_total |
counter | Supervisor restarts, labelled by task |
stellargate_task_running |
gauge | 1 if the named task is running |
stellargate_task_consecutive_failures |
gauge | Consecutive panics since the last stable run |
stellargate_tasks_expected |
gauge | Workers this deployment expects to be running, excluding any disabled by configuration |
stellargate_tasks_live |
gauge | Expected workers currently running |
stellargate_task_disabled |
gauge | 1 if the named task exited because configuration gave it nothing to do |
stellargate_horizon_poll_cycles_total |
counter | Horizon poll cycles, labelled by outcome (success, rate_limited, error) |
stellargate_horizon_last_successful_poll_timestamp_seconds |
gauge | Unix timestamp of the last successful Horizon poll or stream event |
stellargate_horizon_cursor_age_seconds |
gauge | Age of the most recently processed Horizon payment record — the sharpest signal of payment-detection lag |
stellargate_http_requests_total |
counter | HTTP requests, labelled by method, matched route (never the raw path — bounded cardinality), and status |
stellargate_http_request_duration_seconds |
histogram | HTTP request latency, labelled by method and matched route |
stellargate_payments_total |
counter | Payments labelled by lifecycle status (created, completed, overpaid, underpaid, expired) |
stellargate_payment_settlement_latency_seconds |
histogram | Time from payment creation to a settlement outcome |
stellargate_db_pool_connections |
gauge | SQLite connection pool size, labelled by state (idle, in_use) |
stellargate_db_pool_max_connections |
gauge | Configured maximum pool size |
stellargate_db_file_size_bytes |
gauge | On-disk size of the SQLite database files, labelled by file (main, wal, shm); absent for an in-memory database |
stellargate_missing_trustlines |
gauge | 1 if the gateway account has no usable trustline for this asset (absent or unauthorized); 0 if confirmed usable |
stellargate_trustline_unauthorized |
gauge | 1 if the trustline exists but is_authorized=false (issuer revoked/has not granted authorization) |
stellargate_trustline_headroom_stroops |
gauge | Remaining trustline capacity in stroops (limit - balance); alert when near your typical payment size |
stellargate_trustline_check_failures_total |
counter | Trustline checks that failed to reach Horizon (does not affect stellargate_missing_trustlines) |
stellargate_trustline_check_last_success_timestamp_seconds |
gauge | Unix timestamp of the last confirmed trustline check; 0 until first success |
Alert on stellargate_tasks_live < stellargate_tasks_expected. That
comparison was not previously possible: stellargate_tasks_stopped_total was
overloaded across clean shutdown, configuration-disabled exit and fault, so
started − stopped − failed was not a live count and there was nothing to
compare it against. stellargate_task_disabled is what separates "switched off
on purpose" from "not running", which stellargate_task_running alone reports
identically.
Structured logs (via tracing) carry a request_id field on every request span, matching the x-request-id response header returned to clients. Every log line emitted during a request — including internal errors (AppError), audit events, and auth denials — inherits request_id automatically from the span context. Operators can search logs directly using the request ID returned in response headers:
# Filter JSON logs by request ID quoted by a client:
jq 'select(.request_id == "7f3a...")' /var/log/stellargate.log
# Grep structured text logs by request ID:
grep 'request_id=7f3a...' /var/log/stellargate.logSettlement logs include settlement_latency_secs, and both listeners log cursor_age_secs so poller lag is visible before a merchant notices.
Control verbosity with RUST_LOG, e.g. RUST_LOG=stellargate=debug,tower_http=debug.
Every state-changing operation emits a structured tracing event at info
(or warn, for revocation) carrying audit = true, so audit records can be
filtered or routed to a separate sink from ordinary operational logs — e.g.
RUST_LOG doesn't distinguish them, but a JSON-formatted log pipeline can
select on the audit field.
| Field | Meaning |
|---|---|
audit |
Always true. The marker field to select on. |
action |
What happened, as resource.verb — see the table below. |
actor |
"merchant" (acting via their API key) or "admin" (acting via X-Admin-Secret). |
merchant_id |
The merchant that owns the credential used, or (for merchant.provision) the merchant just created. |
source_ip |
The same attributed client IP used for rate limiting and auth logs — see "Client IP attribution" above. |
request_id |
The request's X-Request-Id, so an audit event can be correlated with the access log line and response header for the same request. |
outcome |
What happened to the resource: created, delivered/failed (redelivery), requeued, issued, revoked. |
Plus an id for whatever the event is about (payment_id, delivery_id, key_id).
action |
Emitted from | Notes |
|---|---|---|
payment.create |
POST /payments |
Also carries amount and asset. |
webhook.redeliver |
POST /payments/:id/webhooks/:delivery_id/redeliver |
outcome is the delivery result (delivered/failed), not whether the redelivery request itself was accepted. |
webhook.redeliver_bulk |
POST /payments/webhooks/redeliver |
Carries requeued, the count of deliveries reset to pending. |
merchant.provision |
POST /merchants |
Previously logged only on failure — the single most privileged operation (it mints a credential) now leaves a trace on success too. |
api_key.issue |
POST /merchants/:id/keys |
|
api_key.revoke |
DELETE /merchants/:id/keys/:key_id |
Logged at warn, not info — a revocation is rarer and more consequential than routine issuance. |
DEPLOYMENT.md is the production runbook — pre-flight checklist, first deploy, secrets, backups, upgrades and rollback, alerting signals, and scaling limits.
The target is an Oracle Cloud "Always Free" VM — free with no expiry, and one of the few free tiers offering the persistent disk SQLite requires. The stack is plain Docker Compose (app + Caddy for automatic TLS), so it runs unchanged on any VPS, home server, or Raspberry Pi.
# On the VM — installs Docker, opens the host firewall, adds a systemd unit
curl -fsSL https://raw.githubusercontent.com/StellarGateLabs/StellarGate/main/deploy/setup-oracle.sh | bash
cd ~/StellarGate
cp deploy/stellargate.env.example deploy/stellargate.env
chmod 600 deploy/stellargate.env
nano deploy/stellargate.env # domain, Stellar account, secrets
sudo systemctl start stellargate
curl https://your-domain.com/healthOnly Caddy binds to the host; the gateway is reachable solely over the internal Compose network, so the API cannot be hit over plaintext via the VM's IP.
Most free tiers elsewhere (Render, Cloud Run, Railway) provide no persistent disk and idle the container out. For a payment gateway that means losing the ledger — hence a VM.
⚠️ Run exactly one instance. SQLite permits a single writer, and the background listeners assume they are the only ones running — two instances would each keep their own database and could settle a payment twice. This is the sharpest operational constraint in the system; see Scaling limits.
Schema is applied at startup by db::migrate in src/db.rs, called once from main before the HTTP listener binds. It is hand-written Rust, not a migration runner:
- Tables and indexes are created with
CREATE TABLE IF NOT EXISTS/CREATE INDEX IF NOT EXISTS. - New columns on existing tables are added by probing
pragma_table_info(...)first, thenALTER TABLE ... ADD COLUMN. - A few one-time data backfills (populating
processed_transactionsfrom legacy rows, fillingasset_issuerfromACCEPTED_ASSETS, normalising pre-RFC 3339 timestamps) run alongside them. payments.asset_issueris the one backfill that does not run on every boot: it reconstructs the issuer of pre-existing rows from the configured allow-list, so re-running it after anACCEPTED_ASSETSedit would rewrite history a second time. It runs once per database, guarded by akv_statemarker, fromdb::backfill_asset_issuers— and rows created before it are best-effort by nature, since the issuer they were priced in was never recorded.
Every statement is written to be safe to re-run, because all of them run on every boot. There is no version table, nothing is recorded as applied, and the whole sequence is not wrapped in a transaction.
db::migrate is the only schema definition in this repository. There used to be a second, hand-synchronised one — a migrations/ directory of numbered .sql files that nothing ever executed (no sqlx::migrate! call anywhere in the codebase) and that had silently drifted to the point of missing merchants, api_keys, processed_transactions, and the webhook_deliveries.event_type column: a database built from those files could not authenticate a request or record a settlement. It looked authoritative — numbered, in the conventional location — which made it actively misleading rather than merely unused, so it was removed rather than left as a second definition a future change could drift from again (issue #308). tests/schema_snapshot_test.rs now keeps db::migrate itself honest: it asserts a freshly migrated database matches the checked-in tests/schema_snapshot.sql exactly, so a schema change that isn't reflected there fails CI instead of drifting silently — the same failure mode the old migrations/ directory had, closed by making the live schema self-verifying instead of hand-copied.
Changing the schema
- Add the statement to
db::migrateinsrc/db.rs, keeping it idempotent — it will run on every startup of every existing deployment. - For a new column on an existing table, follow the
pragma_table_infoprobe pattern already used forexpires_atandevent_type. SQLite rejects a non-constantDEFAULTonALTER TABLE ... ADD COLUMN, so add the column nullable and backfill it in a second statement. - Run
cargo test— the suite callsdb::migrateagainst an in-memory database, so syntax errors surface immediately, andtests/schema_snapshot_test.rswill fail with the new schema's exact text, ready to paste intotests/schema_snapshot.sql. - Review the snapshot diff like any other schema change, and update this section's tables/docs if you added something a reader would need to know about.
Because there is no version tracking, a change that is not safe to re-run cannot currently be expressed. If you need one, resolve #268 (adopting sqlx::migrate! with a recorded schema version) first rather than working around it — that is the bigger, separate change this snapshot test deliberately does not attempt to replace.
cargo build # compile
cargo test # full suite (unit + integration)
cargo fmt # format
cargo clippy --all-targets -- -D warningsCI enforces all four on every pull request, plus a cargo audit RustSec advisory scan (also run weekly on a schedule) and an OpenAPI lint of openapi.yaml. The test suite additionally cross-checks the spec's documented paths against the live router (tests/openapi_contract.rs), so a route added without a matching spec change — or a spec change with no route — fails the build. The test suite runs on both the minimum supported Rust version (1.88) and stable; cargo fmt and cargo clippy currently run on stable only, which can differ from the pinned toolchain you get locally (#294).
deny.toml is present but no workflow runs cargo deny yet, so its license, ban, and duplicate-version policy is not currently enforced (#293).
Test layout
| File | Covers |
|---|---|
tests/api_tests.rs |
Endpoints, validation, auth, pagination, idempotency |
tests/concurrency_tests.rs |
Double-settlement safety under concurrent reconciliation |
tests/rate_limit_tests.rs |
Per-bucket limiting |
tests/webhook_dispatch_tests.rs |
Signing, retries, redrive |
tests/trustline_tests.rs |
Asset trustline checks |
tests/openapi_contract.rs |
openapi.yaml ↔ router: paths resolve, deprecation headers, path-set parity |
Integration tests run against an in-memory SQLite database and a wiremock HTTP server — no network access or external services required.
Important
Every test pool connects with sqlite:file:<random-name>?mode=memory&cache=shared plus min_connections(1), never bare sqlite::memory:. A bare sqlite::memory: DSN gives each pooled connection its own private database — with the multi-connection pools these tests build (the default is more than one connection), a query can silently land on a connection that never saw an earlier query's writes in the same test. tests/concurrency_tests.rs is the sharpest case: it exists to prove single-settlement under concurrent reconciliation (issue #78), which only means something if concurrent tasks can land on genuinely different pooled connections talking to the same database. cache=shared fixes this; a random name per pool keeps parallel test binaries from colliding, and min_connections(1) keeps the shared database alive for the pool's lifetime (SQLite drops it once every connection closes). tests/db_shared_memory_tests.rs proves both halves of this directly — the fixture is shared, and a bare sqlite::memory: DSN is not — so don't reintroduce the bare form (issue #309).
Contributions are welcome. Read CONTRIBUTING.md for setup, coding standards, and the PR process; participation is governed by our Code of Conduct. Scoped, ready-to-pick-up issues are tracked in the issue list.
- Fork the repository
- Branch:
git checkout -b feat/your-feature - Make your changes with tests
- Ensure
cargo test,cargo fmt --check, andcargo clippy --all-targets -- -D warningsall pass - Open a pull request describing the change and its rationale
Found a security vulnerability? Please report it privately — see SECURITY.md.
Release history is kept in CHANGELOG.md.
Released under the MIT License.