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.

BTC · 3 confirmations LTC · 1 confirmation

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.

One address, one order
The whole mapping strategy is a fresh address per order. Never reuse an address for two orders — the monitor matches incoming transactions to the order that owns the address.

Quickstart

1. Install and configure

bash
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:

bash
python -c "import secrets; print(secrets.token_hex(32))"

2. Run it

bash
python app.py
# [api] listening on http://127.0.0.1:8000 (BTC conf 3, LTC conf 1)

3. Create your first order

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

Configuration reference
All environment variables: 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:

http
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"}.

Keep it secret
Anyone with this token can read every order and export every private key. Use it only server-to-server, never ship it to clients.

POST /orders

Create an order. Generates a fresh wallet and returns the address to give the customer.

FieldTypeRequiredDescription
coinstringyes"BTC" or "LTC"
amountnumbernoExpected payment in coin units. Order is when received ≥ amount.
amount_usdnumbernoExpected payment in US dollars — auto-converted to coin using the live rate. Mutually exclusive with amount.
amount_eurnumbernoExpected payment in euros — auto-converted to coin using the live rate. Mutually exclusive with amount.
labelstringnoYour reference, up to 200 chars. Echoed back in responses.

curl

bash
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

bash
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:

json
{
  "amount": 1.13765643,
  "rates": {"usd": 43.95, "eur": 37.99},
  ...
}

Python (stdlib)

python
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

json
{
  "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 paramValuesDescription
statuspending · paid · underpaidOptional. Omit to list everything.
bash
curl http://127.0.0.1:8000/orders?status=paid \
  -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.

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

json
{
  "mnemonic": "rack judge lounge ripple great spider kitten miss width unusual senior world",
  "private_key_wif": "T3RkJzgbX5f5re3efqfbfsG7gSto4QhNsJL6ZfGGQbmmu3J4cy6C"
}
Handle with care
Anyone who reaches this endpoint can empty the wallet. Call it only when you are about to sweep funds. For routine payment tracking, watch 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.

FieldTypeDescription
addressstringThe order's address. The API finds the order by it.
hashstringTransaction id. Used for dedupe.
valuenumberAmount received, in coin units.
confirmationsnumberOptional. Stored for your records.
bash
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.

json
{"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.

FieldTypeDescription
idstringOrder id, 16 hex chars.
coinstringBTC or LTC.
addressstringThe customer's payment address. P2PKH — BTC starts 1/3, LTC starts L/M.
amountnumber|nullExpected amount in coin units, or null if not set.
labelstring|nullYour reference, echoed back.
statusstringpending · · underpaid
receivednumberTotal confirmed amount received, coin units.
confirmationsnumberConfirmations of the most recent matching transaction.
txidsarrayTransaction ids already counted toward this order.
ratesobjectCoin price in fiat at creation time, e.g. {"usd": 62993.0, "eur": 54445.0}. Empty {} if no live rate was available.
created_atnumberUnix timestamp, seconds.
updated_atnumberUnix timestamp of the last state change.

Order lifecycle

createpending
tx confirmedpaid
received < amountunderpaid
  • pending — waiting for funds. This is the resting state after POST /orders.
  • — 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.

No amount set?
The first confirmed transaction flips the order to 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:

python
# 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.

CodeMeaningExample body
400Bad request — validation failed.{"error": "coin must be BTC or LTC"}
401Missing or wrong bearer token.{"error": "invalid token"}
404Unknown order or address.{"error": "order not found"}
No amount validation
The API does not reject "wrong" amounts. A tx that is less than expected produces 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_KEY before touching SQLite. Loss of SECRET_KEY makes stored secrets unrecoverable, so back it up with the database.
  • API token — a single bearer token guards everything. Rotate it in .env and restart.
  • Binding — the server listens on 127.0.0.1 by default (HOST env 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 secret field. The secret endpoint is the only way out.