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
- 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.
| 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. CODE for native (XLM) or CODE:ISSUER (USDC:GA…). Adding an asset is config-only — but see Trustlines. Each issuer is strkey-validated at boot. |
XLM,USDC:<testnet issuer> |
REQUEST_TIMEOUT_SECS |
Whole-request timeout; exceeding it returns 408 |
30 |
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.
To check what the account currently trusts:
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 |
PAYMENT_TTL_SECS |
How long an intent stays pending before expiring, from created_at |
3600 |
| 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 |
Delay between inline retries | 5000 |
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 |
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. | 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. 0 disables growth. |
30 |
WEBHOOK_REDRIVE_BACKOFF_MAX_SECS |
Backoff ceiling. Must be ≥ the initial value. |
900 |
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×. | 10 |
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 |
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
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.
| 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.
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.
| Code | HTTP | Meaning |
|---|---|---|
unauthorized |
401 |
Missing/invalid API key or admin secret |
invalid_request |
400 |
Malformed JSON or a deserialization failure |
unsupported_media_type |
415 |
Content-Type is not application/json |
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 |
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 |
webhook_delivery_failed |
502 |
Receiver returned a non-success response |
rate_limit_exceeded |
429 |
Per-IP bucket limit exceeded |
idempotency_conflict |
500 |
Concurrent creates raced on one idempotency key |
not_found |
404 |
No matching route |
internal_error |
500 |
Unexpected server-side failure |
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.
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
}
]
}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.
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. |
webhook_url |
string | ❌ | ≤ 2048 chars; scheme must be allowed; HTTPS required on public; SSRF-checked |
| 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",
"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",
"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 | 20 |
cursor |
Keyset cursor from a previous next_cursor |
— |
offset |
Rows to skip (legacy; prefer cursor) |
0 |
200 OK — cursor mode
{
"payments": [ { "id": "...", "status": "pending" } ],
"limit": 20,
"next_cursor": "3230..."
}next_cursor is null on the final page. Offset mode additionally returns total and offset.
Cursor pagination is keyset-based and stays stable regardless of page depth or concurrent inserts. Offset mode is retained for backward compatibility and can skip or repeat rows if data changes mid-scan.
List every delivery attempt for a payment. Requires the owning merchant's API key.
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"
}
]
}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.
Liveness probe. Returns 200 OK while the process is running.
{ "status": "ok" }Readiness probe. Runs SELECT 1 against the database.
200 OK — { "status": "ok" }
503 Unavailable — { "status": "unavailable" }
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 |
— |
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 are not tracked and fire no webhooks. - Failed on-chain transactions are ignored entirely.
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) |
payment.underpaid |
Payment received but short (delta = shortfall) |
payment.expired |
TTL elapsed with no payment |
{
"event": "payment.overpaid",
"payment_id": "a1b2c3d4-...",
"merchant_id": "your-merchant-id",
"tx_hash": "abc123...",
"amount": "10",
"paid_amount": "12.5",
"asset": "XLM",
"status": "completed",
"delta": "2.5"
}delta is present only on payment.overpaid and payment.underpaid.
| 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.
Bounded requests. Bodies are capped at 256 KiB 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 |
Structured logs (via tracing) carry an x-request-id on every request, propagated to responses. Settlement 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.
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 managed with sqlx::migrate!. Numbered SQL files in migrations/ are applied automatically at startup, so a fresh database and an existing one converge on the same schema. sqlx records applied migrations in _sqlx_migrations, running each exactly once.
Adding a migration
- Create
migrations/<next_number>_<description>.sql(e.g.0003_add_refunds.sql). - Write the
CREATE TABLE/ALTER TABLEstatements. - Run
cargo test— the suite boots against an in-memory database and applies every migration, so syntax errors surface immediately.
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 deny supply-chain audit and a build on both the minimum supported Rust version and stable.
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 |
Integration tests run against an in-memory SQLite database and a wiremock HTTP server — no network access or external services required.
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.