Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

245 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

StellarGate

CI License: MIT Rust

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.


Table of Contents


Overview

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.

How It Works

┌─────────────┐   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.

Features

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

Architecture

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.

Tech Stack

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

Getting Started

Prerequisites

Install and Run

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 run

The API listens on http://localhost:3000 by default.

Docker

The fastest path if you'd rather not install Rust:

cp .env.example .env   # edit as above
docker compose up --build

The SQLite database lives in a named volume (stellargate_data) and survives container restarts. docker compose down stops the stack while preserving that volume.

Verify the Installation

# 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"}'

Dashboard

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, or localStorage if 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', no unsafe-inline) plus X-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_url and memo are 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 /merchants behind ADMIN_PROVISIONING_SECRET. If you expose the gateway publicly, put /dashboard behind your own network controls as well.


Configuration

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.

Core

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

Trustlines

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_ASSETS exactly. USDC from the wrong issuer is a different asset entirely, and a trustline to it will not make payments in the configured one succeed.

Settlement

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

Webhooks

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

Webhook Redrive Worker

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

Retention

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 pending delivery 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 rows failed once 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.

Security and Limits

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

API Reference

Versioning

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.

Deprecation policy

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.


Authentication

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.

Error Envelope

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

POST /merchants

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_key is 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.


POST /merchants/:id/keys

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. label is optional, purely for your own bookkeeping.


GET /merchants/:id/keys

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.


DELETE /merchants/:id/keys/:key_id

Revoke a key. Effective immediately — the next request using it gets 401. Admin only.

200 OK{ "key_id": "…", "revoked": true }

Refuses with last_active_key if 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.


POST /payments

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 amount of asset to destination_address with memo set as a text memo. The intent expires at expires_at if unpaid.


GET /payments/:id

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. A 403 would 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

GET /payments

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.


GET /payments/:id/webhooks

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"
    }
  ]
}

POST /payments/:id/webhooks/:delivery_id/redeliver

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.


GET /health

Liveness probe. Returns 200 OK while the process is running.

{ "status": "ok" }

GET /ready

Readiness probe. Runs SELECT 1 against the database.

200 OK          — { "status": "ok" }
503 Unavailable — { "status": "unavailable" }

GET /metrics

Prometheus exposition format. See Observability.

GET /dashboard

The operator dashboard. Also serves /dashboard/app.css and /dashboard/app.js. See Dashboard.


Payment Resolution Policy

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 delta in 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.

Webhooks

When a payment reaches a terminal state, StellarGate POSTs a signed JSON event to the intent's webhook_url.

Events

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.

Verifying Signatures

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.

  1. Read the timestamp (t) and signature (sig).
  2. Reject if abs(now − t) > tolerance. 5 minutes is recommended.
  3. Concatenate "{t}.{raw_body}" using the exact received bytes — verify before any JSON re-encoding, which would change them.
  4. Compute HMAC_SHA256(WEBHOOK_SECRET, "{t}.{raw_body}"), hex-encoded.
  5. Compare against sig in constant time.
  6. Only after the signature passes, read event from the body.

⚠️ Never route security-sensitive logic on X-StellarGate-Event. It is outside the signed material and can be altered in transit without invalidating the signature. The event field 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 Guarantees

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.


Security Model

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.


Observability

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

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/health

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


Database Migrations

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

  1. Create migrations/<next_number>_<description>.sql (e.g. 0003_add_refunds.sql).
  2. Write the CREATE TABLE / ALTER TABLE statements.
  3. Run cargo test — the suite boots against an in-memory database and applies every migration, so syntax errors surface immediately.

Development

cargo build                 # compile
cargo test                  # full suite (unit + integration)
cargo fmt                   # format
cargo clippy --all-targets -- -D warnings

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


Contributing

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.

  1. Fork the repository
  2. Branch: git checkout -b feat/your-feature
  3. Make your changes with tests
  4. Ensure cargo test, cargo fmt --check, and cargo clippy --all-targets -- -D warnings all pass
  5. 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.


License

Released under the MIT License.

About

No description, website, or topics provided.

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages