A developer-friendly payment gateway API built on Stellar for accepting, verifying, and managing payments in XLM and USDC.
Think Stripe — but powered by the Stellar blockchain instead of banks.
StellarGate abstracts Stellar payments into a simple REST API. Developers can create payment intents, receive a destination address and memo, and get notified when payment is confirmed on-chain.
Client App → POST /payments → get address + memo
User pays via Stellar wallet (e.g. Lobstr)
StellarGate detects transaction on Horizon
Payment marked complete → webhook fired to your app
This project is under active development. The following is implemented:
-
POST /payments— create a payment intent -
GET /payments/:id— query payment status -
GET /payments— list & filter payments (pagination) -
GET /health— health check - SQLite persistence
- Input validation (asset, amount as exact stroops, webhook URL)
- Transaction listener (Horizon SSE streaming + interval polling)
- Payment verification (memo + asset + amount)
- Webhook dispatch (timestamped HMAC-SHA256 signature, replay-resistant, with retries)
- Multi-merchant support (
merchant_idper payment) - Pending-intent expiry (configurable TTL +
payment.expiredwebhook) - Horizon streaming (currently polled on an interval)
- Dashboard UI
- Language: Rust
- HTTP Framework: axum
- Database: SQLite via sqlx
- Async Runtime: tokio
- Blockchain: Stellar Horizon API
- Rust 1.75+ — install via rustup
git clone https://github.com/StellarGateLabs/StellarGate.git
cd StellarGate
cp .env.example .env
# Edit .env with your Stellar keys| Variable | Description | Default |
|---|---|---|
PORT |
HTTP port | 3000 |
DATABASE_URL |
sqlx connection string | sqlite:stellargate.db |
STELLAR_NETWORK |
testnet or public |
testnet |
STELLAR_HORIZON_URL |
Horizon endpoint | testnet |
STELLAR_GATEWAY_PUBLIC |
Your gateway wallet public key (G...). Validated as a Stellar strkey at startup; an invalid value aborts boot. |
— |
ACCEPTED_ASSETS |
Comma-separated assets to accept. Format: CODE for native (e.g. XLM) or CODE:ISSUER for non-native (e.g. USDC:GISSUER). Adding an asset is config-only — no code changes needed. Each ISSUER is validated as a Stellar strkey at startup. |
XLM,USDC:<testnet-issuer> |
STELLAR_LISTENER_MODE |
stream (SSE + poller reconciler) or poll (interval only) |
stream |
POLL_INTERVAL_SECS |
How often the Horizon poller reconciles | 10 |
PAYMENT_TTL_SECS |
How long a payment intent stays pending before it is expired (from created_at) |
3600 |
WEBHOOK_SECRET |
HMAC signing secret for webhooks | — |
WEBHOOK_RETRY_ATTEMPTS |
Webhook delivery attempts | 3 |
WEBHOOK_RETRY_DELAY_MS |
Delay between webhook retries | 5000 |
WEBHOOK_TIMEOUT_SECS |
Per-attempt timeout (seconds) for outbound webhook POSTs. Each retry is bounded independently; a slow receiver cannot block the reconciler for longer than this value × retries. | 10 |
WEBHOOK_REDRIVE_INTERVAL_SECS |
How often the background redrive worker scans for stuck webhook deliveries (rows left pending/failed by a process that exited mid-delivery). Its first pass runs immediately on startup, so a restart redrives without waiting a full interval. |
30 |
WEBHOOK_REDRIVE_CONCURRENCY |
Maximum redrive HTTP attempts in flight at once. | 4 |
WEBHOOK_REDRIVE_MAX_ATTEMPTS |
Total attempts (inline + redrive) before a delivery is left failed permanently. |
8 |
WEBHOOK_REDRIVE_GRACE_SECS |
How long (seconds) a delivery must sit idle since its last attempt before the redrive worker will touch it, so it never races a still-in-flight inline delivery for the same row. Also the floor under the backoff below. | 60 |
WEBHOOK_REDRIVE_BACKOFF_INITIAL_SECS |
Starting delay (seconds) of the exponential backoff applied to redrive attempts once a delivery has failed at least once (initial * 2^(attempts-1), capped by WEBHOOK_REDRIVE_BACKOFF_MAX_SECS). A row never attempted (crash before its first send) is exempt and gated by WEBHOOK_REDRIVE_GRACE_SECS alone. Set to 0 to disable growth. |
30 |
WEBHOOK_REDRIVE_BACKOFF_MAX_SECS |
Upper bound (seconds) on the backoff above. Must be >= WEBHOOK_REDRIVE_BACKOFF_INITIAL_SECS. |
900 |
WEBHOOK_ALLOW_PRIVATE_TARGETS |
Bypasses the SSRF guard's loopback/link-local/private/reserved IP check on webhook_url (still requires http(s) and a resolvable host). For local development and tests only — never enable in production. |
false |
CORS_ALLOWED_ORIGINS |
Comma-separated allowed CORS origins (e.g. https://app.example.com). Required on public network; omitting on testnet falls back to permissive with a warning. |
(unset — permissive on testnet) |
RATE_LIMIT_REQUESTS_PER_SEC |
Rate limit for POST /payments and POST /merchants (requests per second per IP, tracked independently per route) |
10 |
REQUEST_TIMEOUT_SECS |
Per-request timeout for the whole API. A request without a response within this window is aborted with 408 Request Timeout. |
30 |
DB_POOL_MAX_CONNECTIONS |
SQLite connection pool size. WAL mode allows one writer + many concurrent readers. | 10 |
DB_BUSY_TIMEOUT_MS |
How long (ms) SQLite waits to acquire a write lock before returning an error. Must be > 0 under concurrent load. |
5000 |
ADMIN_PROVISIONING_SECRET |
Shared secret required via the X-Admin-Secret header to call POST /merchants. Unset disables provisioning entirely (every request gets 401). |
(unset — provisioning disabled) |
DATABASE_URLis a sqlx connection string (sqlite:stellargate.db), not a file path. The Horizon poller stays idle untilSTELLAR_GATEWAY_PUBLICis set. The poller pages forward through payments from a cursor persisted in the database, so it never misses an intent regardless of on-chain volume and resumes from where it left off after a restart.The gateway never holds a secret key and never signs or submits Stellar transactions — it only watches
STELLAR_GATEWAY_PUBLICfor incoming payments. Overpayment refunds are the merchant's responsibility, triggered by thepayment.overpaidwebhook event (see below); the gateway does not perform them automatically.
cargo runThe quickest way to run StellarGate without installing Rust:
cp .env.example .env
# Edit .env with your Stellar keys, then:
docker compose up --buildThe API will be available at http://localhost:3000. The SQLite database is
stored in a named Docker volume (stellargate_data) so it persists across
container restarts. Verify the service is healthy:
curl http://localhost:3000/health
# {"status":"ok"}To stop and remove containers while keeping the database volume:
docker compose downcargo testTests cover amount/stroops handling, Horizon payment verification, webhook signing, and the HTTP API (create, fetch, list/filter, validation).
All public error responses use the same envelope:
{
"error": "A human-readable explanation",
"code": "stable_machine_readable_code"
}The code field is stable and should be handled programmatically. The table below documents the public error codes currently returned by the API.
| Code | HTTP status | Meaning | Typical condition | Endpoints |
|---|---|---|---|---|
unauthorized |
401 Unauthorized |
Missing or invalid authentication. | Missing or invalid Authorization header, invalid API key, or invalid admin secret. |
POST /merchants, POST /payments, GET /payments, GET /payments/:id/webhooks, POST /payments/:id/webhooks/:delivery_id/redeliver |
internal_error |
500 Internal Server Error |
Unexpected server-side failure. | An internal error occurred while processing the request. | All endpoints that hit server-side execution paths |
invalid_request |
400 Bad Request |
The request body is invalid. | Malformed JSON, missing content type, or another deserialization failure. | POST /payments |
unsupported_asset |
400 Bad Request |
The requested asset is not accepted by the gateway. | The asset is not one of the configured accepted assets. | POST /payments |
invalid_amount |
400 Bad Request |
The amount is invalid. | The amount is not a positive decimal value with at most 7 decimal places. | POST /payments |
invalid_webhook_url |
400 Bad Request |
The webhook URL is invalid or rejected. | The webhook URL is not a valid URL or is rejected by the SSRF validation rules. | POST /payments |
invalid_status |
400 Bad Request |
The requested status filter is not valid. | The status query parameter is not one of the supported values. |
GET /payments |
invalid_cursor |
400 Bad Request |
The pagination cursor is malformed. | The cursor query parameter cannot be decoded. |
GET /payments |
payment_not_found |
404 Not Found |
The requested payment does not exist or is not owned by the caller. | The payment ID does not exist or belongs to a different merchant. | GET /payments/:id, GET /payments/:id/webhooks |
delivery_not_found |
404 Not Found |
The requested webhook delivery does not exist or is not related to the payment. | The delivery ID does not exist or does not belong to the payment. | POST /payments/:id/webhooks/:delivery_id/redeliver |
rate_limit_exceeded |
429 Too Many Requests |
The client exceeded the per-bucket rate limit. | Too many requests hit the same rate-limit bucket within the configured window. | POST /payments, POST /merchants, POST /payments/:id/webhooks/:delivery_id/redeliver |
not_found |
404 Not Found |
No matching route was found. | The request path does not match any known route. | Unmatched routes |
webhook_target_blocked |
400 Bad Request |
The redelivery target is not allowed. | The redelivery URL is blocked by the SSRF guard. | POST /payments/:id/webhooks/:delivery_id/redeliver |
webhook_delivery_failed |
502 Bad Gateway |
The webhook redelivery failed. | The downstream webhook endpoint returned a non-success response. | POST /payments/:id/webhooks/:delivery_id/redeliver |
idempotency_conflict |
500 Internal Server Error |
A concurrent create request conflicted on the same idempotency key. | Two concurrent POST /payments requests reused the same idempotency key. |
POST /payments |
Provision a new merchant and return its API key. This is an admin-only
route: set ADMIN_PROVISIONING_SECRET and send it via the X-Admin-Secret
header, or the endpoint always returns 401. There is no self-service
sign-up — provisioning is intended to be run by whoever operates the gateway
(e.g. an internal admin tool or a one-off curl from a trusted machine), not
exposed to end users.
Request
curl -X POST http://localhost:3000/merchants \
-H "X-Admin-Secret: $ADMIN_PROVISIONING_SECRET"Response 201 Created
{
"merchant_id": "a1b2c3d4-...",
"api_key": "e5f6...-...-..."
}
api_keyis returned once, in plaintext, and never shown again — store it securely. Use it as theAuthorization: Bearer <api_key>header onPOST /paymentsandGET /payments.
Create a new payment intent.
Request
{
"amount": "10.00",
"asset": "XLM",
"merchant_id": "your-merchant-id",
"webhook_url": "https://yourapp.com/webhooks/stellar"
}| Field | Type | Required | Values |
|---|---|---|---|
amount |
string | ✅ | Any positive number |
asset |
string | ✅ | XLM or USDC |
merchant_id |
string | ❌ | Any string |
webhook_url |
string | ❌ | Valid HTTPS URL (HTTP permitted only in testnet/development) |
webhook_urlis checked against an SSRF guard: its host is resolved and rejected if it's loopback, link-local (including the cloud metadata address169.254.169.254), private, or otherwise reserved. The same check runs again on every redelivery (POST /payments/:id/webhooks/:delivery_id/redeliver) against the exact address resolved, not a second DNS lookup, so a DNS-rebinding attempt after the initial check can't reach an internal host.
Headers
| Header | Required | Description |
|---|---|---|
Idempotency-Key |
❌ | Opaque client-chosen key for safe retries. Reusing a key (scoped per merchant_id) returns the original payment with 200 OK instead of minting a duplicate intent. |
Response 201 Created (or 200 OK when an Idempotency-Key matches a prior request)
{
"id": "a1b2c3d4-...",
"destination_address": "GBBD47IF6LWK7P7...",
"memo": "A1B2C3D4",
"amount": "10.00",
"asset": "XLM",
"status": "pending",
"created_at": "2026-04-29T15:00:00",
"expires_at": "2026-04-29T16:00:00"
}The user must send exactly
amountofassettodestination_addresswithmemoset as the transaction memo. The intent expires atexpires_at(default one hour after creation) if unpaid.
Fetch the current status of a payment.
Response 200 OK
{
"id": "a1b2c3d4-...",
"merchant_id": "your-merchant-id",
"destination_address": "GBBD47IF6LWK7P7...",
"memo": "A1B2C3D4",
"amount": "10.00",
"asset": "XLM",
"status": "pending",
"tx_hash": null,
"paid_amount": null,
"created_at": "2026-04-29T15:00:00",
"updated_at": "2026-04-29T15:00:00",
"expires_at": "2026-04-29T16:00:00"
}Status values
| Status | Meaning |
|---|---|
pending |
Awaiting payment |
completed |
Payment confirmed on-chain |
underpaid |
Less than the requested amount received; stays watchable for a top-up |
expired |
TTL elapsed before payment arrived; no longer watched |
List payments, newest first.
Query parameters
| Param | Description | Default |
|---|---|---|
status |
Filter by pending, completed, underpaid, or expired |
all |
limit |
Page size (1–100) | 20 |
offset |
Rows to skip | 0 |
Response 200 OK
{
"total": 42,
"limit": 20,
"offset": 0,
"payments": [ { "id": "...", "status": "pending", "...": "..." } ]
}Cheap liveness probe. Always returns 200 OK as long as the process is running.
200 OK — { "status": "ok" }Readiness probe. Runs SELECT 1 against the database; returns 503 when unreachable.
200 OK — { "status": "ok" }
503 Unavailable — { "status": "unavailable" }1. Developer calls POST /payments
2. StellarGate returns { destination_address, memo, amount }
3. End user sends payment via any Stellar wallet
4. StellarGate listener detects the transaction on Horizon (SSE stream, ~1s; poller as fallback)
5. Verifies: correct memo + amount + asset
6. Updates payment status and fires a webhook event
Every on-chain payment matched by memo, destination, and asset is resolved as follows:
| Scenario | status |
Webhook event | delta field |
|---|---|---|---|
| Paid exactly the requested amount | completed |
payment.completed |
not present |
| Paid more than requested | completed |
payment.overpaid |
excess amount (should be refunded) |
| Paid less than requested | underpaid |
payment.underpaid |
shortfall still owed |
| Top-up brings cumulative total to exactly expected | completed |
payment.completed |
not present |
| Top-up brings cumulative total above expected | completed |
payment.overpaid |
cumulative excess |
Overpayment: The intent is fulfilled and moves to completed. The payment.overpaid event includes a delta field showing the excess amount the merchant should consider refunding to the sender.
Underpayment: The intent moves to underpaid and remains watchable. StellarGate continues polling for a follow-up payment to the same memo. When the cumulative total meets or exceeds the requested amount, the intent completes normally.
Top-up limitation: Only a single follow-up payment is tracked per underpaid intent. If multiple partial payments are needed, the sender should consolidate them — send the full remaining shortfall (shown in delta) in one transaction.
Post-completion payments: Once an intent reaches completed, any further on-chain payments to the same address and memo are not tracked and will not trigger additional webhooks.
Fired when the cumulative received amount equals the requested amount exactly.
{
"event": "payment.completed",
"payment_id": "a1b2c3d4-...",
"merchant_id": "your-merchant-id",
"tx_hash": "abc123...",
"amount": "10.00",
"paid_amount": "10",
"asset": "XLM",
"status": "completed"
}Fired when the cumulative received amount exceeds the requested amount. delta is the excess the merchant should refund.
{
"event": "payment.overpaid",
"payment_id": "a1b2c3d4-...",
"merchant_id": "your-merchant-id",
"tx_hash": "abc123...",
"amount": "10.00",
"paid_amount": "12.5",
"asset": "XLM",
"status": "completed",
"delta": "2.5"
}Fired when a payment is received but falls short of the requested amount. delta is the remaining shortfall. The intent stays open for a top-up.
{
"event": "payment.underpaid",
"payment_id": "a1b2c3d4-...",
"merchant_id": "your-merchant-id",
"tx_hash": "abc123...",
"amount": "10.00",
"paid_amount": "7",
"asset": "XLM",
"status": "underpaid",
"delta": "3"
}See WEBHOOK_REFERENCE.md for the canonical webhook documentation, including all event types, signature verification, and integration examples.
payment.completed (paid in full), payment.overpaid (excess payment), payment.underpaid (shortfall remaining), and payment.expired (TTL elapsed). The event field in the signed body carries the authoritative type.
Every webhook request carries two headers that together authenticate the event:
| Header | Value |
|---|---|
X-StellarGate-Timestamp |
Unix time (seconds) at which the event was signed |
X-StellarGate-Signature |
Hex HMAC-SHA256 of "{timestamp}.{raw_body}", keyed with your WEBHOOK_SECRET |
A third header, X-StellarGate-Event, is included as a routing convenience (e.g. to quickly filter events in a load balancer before parsing JSON). This header is not part of the signed material — it mirrors the event field in the body but can be altered in transit without invalidating the signature. Always verify the signature first, then read the event type from the signed body.
The signature covers the timestamp as well as the body (Stripe-style), so a captured request cannot be replayed indefinitely. To verify:
- Read
X-StellarGate-Timestamp(t) andX-StellarGate-Signature(sig). - Reject the request if
tis too old:abs(now - t) > tolerance. A 5-minute tolerance is recommended — large enough for clock skew and network delay, small enough to bound the replay window. - Concatenate
"{t}.{raw_body}"using the exact bytes received (verify before any JSON re-encoding, which would change the bytes). - Compute
HMAC_SHA256(WEBHOOK_SECRET, "{t}.{raw_body}")and hex-encode it. - Compare it to
sigwith a constant-time equality check. Reject on mismatch. - After the signature passes, read the
eventfield from the body to determine the event type. Do not route onX-StellarGate-Eventfor security-sensitive logic.
Example (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 — reject
}
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}
// Usage: always read the event type from the verified body, never from the header.
// X-StellarGate-Event is a convenience header only — it is NOT signed.
function handleWebhook(rawBody, headers, secret) {
if (!verify(rawBody, headers, secret)) {
throw new Error("invalid signature");
}
const payload = JSON.parse(rawBody);
const event = payload.event; // ← authenticated; safe to route on
// const event = headers["x-stellargate-event"]; // ← NOT authenticated; do not use
switch (event) {
case "payment.completed": /* ... */ break;
case "payment.overpaid": /* ... */ break;
case "payment.underpaid": /* ... */ break;
case "payment.expired": /* ... */ break;
}
}migrations/
└── 0001_initial_schema.sql # Versioned schema applied automatically on startup
src/
├── main.rs # Entry point, server startup, listener/poller spawn, graceful shutdown
├── lib.rs # Shared state and module exports
├── config.rs # Environment configuration
├── db.rs # Database queries (SQLite)
├── money.rs # Stroops-based amount parsing/validation
├── strkey.rs # Stellar address (strkey) validation
├── horizon.rs # Horizon polling listener + payment verification
├── expiry.rs # Background sweeper that expires overdue pending intents
├── webhook.rs # HMAC-SHA256 signed webhook dispatch + background redrive worker
└── api/
├── mod.rs # Axum router, layers (CORS/trace/body-limit), 404 fallback
└── payments.rs # Payment handlers (create, get, list)
tests/
└── api_tests.rs # Integration tests
Schema is managed with sqlx::migrate!. Migrations live in migrations/ as numbered SQL files and are applied automatically on startup — both a fresh database and an existing one converge to the same schema.
Adding a migration:
- Create
migrations/<next_number>_<short_description>.sql(e.g.0002_add_refunds_table.sql). - Write your
ALTER TABLE/CREATE TABLESQL in the file. - Run
cargo test— the test suite boots against an in-memory database and will apply all migrations, catching syntax errors early.
sqlx records applied migrations in a _sqlx_migrations table so each file is run exactly once.
This project is open to contributors. See the Wave Program for scoped issues you can pick up, and read CONTRIBUTING.md for setup, standards, and the PR process. Participation is governed by our Code of Conduct.
To contribute:
- Fork the repo
- Create a branch:
git checkout -b feat/your-feature - Make your changes and add tests
- Run
cargo test— all tests must pass - Open a pull request
Found a security vulnerability? Please report it privately — see SECURITY.md.
MIT