STELLAR PAYMENT API v1.0
Take payments in Bitcoin and Litecoin. On your own server.
Every order gets a fresh wallet address. The API watches it for incoming transactions and tells you when the payment clears. No external payment processors, no accounts, no middlemen.
New here? Start with Getting started — a step-by-step walkthrough that ends with your first real payment.
Overview
Stellar Payment API is a small Python service that turns a BTC or LTC address into a payment flow:
- Create an order — the API generates a wallet (BIP39 mnemonic, BIP44 path, P2PKH address) and returns a unique address for the customer to pay into.
- It watches that address — a background poller queries BlockCypher every 30 seconds, sums outputs to your address, and dedupes by transaction id.
- You learn the result — poll the order, or push confirmed transactions in via the webhook endpoint for instant delivery.
Private keys are stored in SQLite, encrypted at rest with AES-256-GCM. The only time you touch a key is the sweep: import the WIF key wherever you keep funds.
Quickstart
1. Install and configure
cd payment-api
pip install -r requirements.txt
cp .env.example .env
Generate a secret key and paste it into .env along with an API token of your own:
python -c "import secrets; print(secrets.token_hex(32))"
2. Run it
python app.py
# [api] listening on http://127.0.0.1:8000 (BTC conf 3, LTC conf 1)
3. Create your first order
curl -X POST http://127.0.0.1:8000/orders \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"coin": "LTC", "amount": 5.0, "label": "order-42"}'
The response contains the address — show that to the customer. Check status until it flips to paid.
SECRET_KEY (required, 16+ chars), API_TOKEN (required), BLOCKCYPHER_TOKEN (optional, raises rate limits from 3 req/s), PORT (default 8000), DB_PATH (default ./payments.db), HOST (default 127.0.0.1), BTC_CONFIRMATIONS (default 3), LTC_CONFIRMATIONS (default 1).
Authentication
Every request except GET /health requires a bearer token. Send it as an Authorization header:
Authorization: Bearer <API_TOKEN>
The token is set in .env (API_TOKEN). Requests without it, or with a wrong token, get 401 {"error": "invalid token"}.
POST /orders
Create an order. Generates a fresh wallet and returns the address to give the customer.
| Field | Type | Required | Description |
|---|---|---|---|
coin | string | yes | "BTC" or "LTC" |
amount | number | no | Expected payment in coin units. Order is paid when received ≥ amount. |
amount_usd | number | no | Expected payment in US dollars — auto-converted to coin using the live rate. Mutually exclusive with amount. |
amount_eur | number | no | Expected payment in euros — auto-converted to coin using the live rate. Mutually exclusive with amount. |
label | string | no | Your reference, up to 200 chars. Echoed back in responses. |
curl
curl -X POST http://127.0.0.1:8000/orders \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"coin": "BTC", "amount": 0.01}'
curl — price in fiat
curl -X POST http://127.0.0.1:8000/orders \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"coin": "LTC", "amount_usd": 50}' # or amount_eur
The API converts to coin using the live rate and stores the rate on the order:
{
"amount": 1.13765643,
"rates": {"usd": 43.95, "eur": 37.99},
...
}
Python (stdlib)
import json, urllib.request
req = urllib.request.Request(
"http://127.0.0.1:8000/orders",
data=json.dumps({"coin": "LTC", "amount": 5.0}).encode(),
headers={"Authorization": "Bearer YOUR_API_TOKEN",
"Content-Type": "application/json"},
method="POST",
)
order = json.loads(urllib.request.urlopen(req).read())
print(order["address"]) # give this to the customer
print(order["id"]) # use this to check status
Response — 201 Created
{
"id": "467d856742701c47",
"coin": "LTC",
"address": "Lg1vJdAcwCNqCGUafpaLSAhSLSZ6aGm2GH",
"amount": 5.0,
"label": null,
"status": "pending",
"received": 0.0,
"confirmations": 0,
"txids": [],
"created_at": 1786777356,
"updated_at": 1786777356
}
amount is null when you did not set one. The secret field never appears here — export keys via the dedicated endpoint below.
GET /orders
List all orders, in the order they were created. Filter with the status query parameter.
| Query param | Values | Description |
|---|---|---|
status | pending · paid · underpaid | Optional. Omit to list everything. |
label | string | Optional. Find the order(s) by your label — recovery when you lost the id. |
address | string | Optional. Find the order by payment address. |
curl http://127.0.0.1:8000/orders?status=paid \
-H "Authorization: Bearer YOUR_API_TOKEN"
Recovery: find a lost deal by label or address — every order id is 16 hex chars, e.g. 467d856742701c47.
curl "http://127.0.0.1:8000/orders?label=order-42" \
-H "Authorization: Bearer YOUR_API_TOKEN"
curl "http://127.0.0.1:8000/orders?address=Lg1vJdAcwCNqCGUafpaLSAhSLSZ6aGm2GH" \
-H "Authorization: Bearer YOUR_API_TOKEN"
Response is an array of order objects.
GET /orders/{id}
Fetch one order. This is your poll endpoint — check it on a timer, or when a webhook fires.
curl http://127.0.0.1:8000/orders/467d856742701c47 \
-H "Authorization: Bearer YOUR_API_TOKEN"
Response is an order object. Unknown ids return 404 {"error": "order not found"}.
GET /orders/{id}/secret
Export the wallet's recovery phrase and WIF private key. This is the only endpoint that returns key material.
{
"mnemonic": "rack judge lounge ripple great spider kitten miss width unusual senior world",
"private_key_wif": "T3RkJzgbX5f5re3efqfbfsG7gSto4QhNsJL6ZfGGQbmmu3J4cy6C"
}
status instead — you never need the key until you move the money.
POST /webhooks/{coin}
Report a confirmed transaction to an order. Idempotent by hash — duplicate deliveries are ignored, so a retry is safe.
| Field | Type | Description |
|---|---|---|
address | string | The order's address. The API finds the order by it. |
hash | string | Transaction id. Used for dedupe. |
value | number | Amount received, in coin units. |
confirmations | number | Optional. Stored for your records. |
curl -X POST http://127.0.0.1:8000/webhooks/LTC \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"address": "Lg1vJdAcwCNqCGUafpaLSAhSLSZ6aGm2GH",
"hash": "tx123", "value": 5.0, "confirmations": 1}'
# {"ok": true}
Unknown addresses return 404. See Webhook delivery for how to feed it from BlockCypher's websocket.
GET /health
Liveness probe. No auth required — point your uptime checker at it.
{"ok": true, "uptime": 86400.5}
Order object
Every endpoint that returns an order uses this shape. Fields are stable; add your own mapping by id or label.
| Field | Type | Description |
|---|---|---|
id | string | Order id, 16 hex chars. |
coin | string | BTC or LTC. |
address | string | The customer's payment address. P2PKH — BTC starts 1/3, LTC starts L/M. |
amount | number|null | Expected amount in coin units, or null if not set. |
label | string|null | Your reference, echoed back. |
status | string | pending · paid · underpaid |
received | number | Total confirmed amount received, coin units. |
confirmations | number | Confirmations of the most recent matching transaction. |
txids | array | Transaction ids already counted toward this order. |
rates | object | Coin price in fiat at creation time, e.g. {"usd": 62993.0, "eur": 54445.0}. Empty {} if no live rate was available. |
created_at | number | Unix timestamp, seconds. |
updated_at | number | Unix timestamp of the last state change. |
Order lifecycle
- pending — waiting for funds. This is the resting state after
POST /orders. - paid — received ≥ amount, or any confirmed transaction when no amount was set. Ship the goods.
- underpaid — confirmed funds arrived but never reached the amount. Decide how to handle it; the order will not move again on its own.
BTC orders need 3 confirmations before they count; LTC needs 1. Both are configurable in .env (BTC_CONFIRMATIONS, LTC_CONFIRMATIONS). Multiple transactions to the same address accumulate into received — a customer who splits the payment still lands in paid once the total is enough.
paid with whatever arrived. Use this for donations or anything that accepts any amount.
Webhook delivery
The built-in poller checks every pending order every 30 seconds. That is fine for most flows, but if you want instant payment detection, subscribe to BlockCypher's websocket and forward confirmed transactions:
# Connect to: wss://socket.blockcypher.com/v1/ltc/main?token=YOUR_TOKEN
# Subscribe: {"event": "unconfirmed-tx", "address": "Lg1vJdAcwCNqCGUafpaLSAhSLSZ6aGm2GH"}
# On each unconfirmed-tx event, forward it after it confirms:
import json, urllib.request
req = urllib.request.Request(
"http://127.0.0.1:8000/webhooks/LTC",
data=json.dumps({
"address": event_address,
"hash": tx_hash,
"value": value_in_ltc,
}).encode(),
headers={"Authorization": "Bearer YOUR_API_TOKEN",
"Content-Type": "application/json"},
method="POST",
)
urllib.request.urlopen(req)
Duplicates are ignored by hash, so forwarding both the websocket event and the poller's finding never double-counts a payment.
Errors
Errors are JSON with a single error field. Always check it — the message is specific about what to fix.
| Code | Meaning | Example body |
|---|---|---|
400 | Bad request — validation failed. | {"error": "coin must be BTC or LTC"} |
401 | Missing or wrong bearer token. | {"error": "invalid token"} |
404 | Unknown order or address. | {"error": "order not found"} |
underpaid, and the decision of what to do next is yours.
Security
- Keys at rest — mnemonic and WIF are AES-256-GCM encrypted with
SECRET_KEYbefore touching SQLite. Loss ofSECRET_KEYmakes stored secrets unrecoverable, so back it up with the database. - API token — a single bearer token guards everything. Rotate it in
.envand restart. - Binding — the server listens on
127.0.0.1by default (HOSTenv var). Expose it only behind a reverse proxy with TLS if other machines must reach it. - Never leak keys — public order responses never include the
secretfield. The secret endpoint is the only way out.