Python API SDK
Install, configure, and call from a Python project.
Install
pip install stableopsRequires Python 3.8+ and depends on httpx and pydantic v2. It ships both a
synchronous StableOps and an asynchronous AsyncStableOps client with an
identical API shape.
Want a runnable end-to-end example?
The Playground walks through create → wallet payment → confirm → finalized in the browser, with source you can read.
Configure
import os
from stableops import StableOps
client = StableOps(
api_key=os.environ["STABLEOPS_API_KEY"],
# Optional:
# base_url="https://api.stableops.dev",
# timeout=30.0,
# max_retries=2,
# checkout_base_url="https://pay.stableops.dev", # only affects checkout url building
)The environment (sandbox / live) is determined by the API key prefix
(sk_sandbox_… / sk_live_…) — no extra parameter needed.
Async usage:
import asyncio
from stableops import AsyncStableOps
async def main() -> None:
async with AsyncStableOps(api_key="sk_sandbox_...") as client:
order = await client.payment_orders.create(
merchant_order_id="sub_89231_2026_06",
amount="49.00",
accepted_assets=[{"chain": "base", "asset": "USDC"}],
expires_at="2026-06-20T12:30:00Z",
)
print(order.id)
asyncio.run(main())Payment orders
from datetime import datetime, timedelta, timezone
order = client.payment_orders.create(
merchant_order_id="sub_89231_2026_06",
amount="49.00",
accepted_assets=[
{"chain": "base", "asset": "USDC"},
{"chain": "tron", "asset": "USDT"},
],
# Auto-expire after 30 minutes; the order moves to `expired` and the address is released.
expires_at=(datetime.now(timezone.utc) + timedelta(minutes=30)).isoformat(),
# Optional: 'auto' nudges the amount to a unique value (no manual offset on SHARED addresses).
amount_mode="auto",
)
client.payment_orders.retrieve(order.id)
client.payment_orders.list(status="detected", limit=50)
client.payment_orders.cancel(order.id)merchant_order_id doubles as the idempotency key, so retries from your worker
land on the same record instead of creating duplicates.
Amounts come back as string in smallest units — don't reach for float(amount).
requested_amount is the base amount you submitted (for an auto order, the
pre-nudge amount, used for reconciliation).
Checkout sessions (hosted)
checkout_sessions.create returns a hosted (WalletConnect) payment page — redirect
the user to session.url.
session = client.checkout_sessions.create(
merchant_order_id="sub_89231_2026_06",
amount="49.00",
accepted_assets=[{"chain": "base", "asset": "USDC"}],
expires_at="2026-06-20T12:30:00Z",
title="Pro plan",
success_url="https://your-app.example.com/pay/success",
cancel_url="https://your-app.example.com/pay/cancel",
)
print(session.url) # send the user here to payWebhook endpoints
endpoint = client.webhooks.create_endpoint(
url="https://your-app.example.com/hooks/stableops",
enabled_events=["payment.detected", "payment.confirmed", "payment.finalized"],
# Optional: strip order metadata from the delivered payload.
redact_metadata=True,
)
endpoints = client.webhooks.list_endpoints()
client.webhooks.update_endpoint(endpoint.id, description="Production")
# endpoint.secret is only present on create/rotate. Store it somewhere durable.
client.webhooks.rotate_secret(endpoint.id)Deliveries and replay
# List deliveries (filter by status / endpoint / order).
deliveries = client.webhooks.list_deliveries(status="failed", limit=20)
# Re-deliver a specific event to a specific endpoint.
client.webhooks.replay(endpoint.id, event_id)
# Replay a single delivery.
client.webhooks.replay_delivery(delivery_id)
# Batch-replay dead letters.
result = client.webhooks.replay_dead_letters(endpoint_id=endpoint.id, limit=100)
print(result.replayed)Webhook verification
Verify the signature inside your callback handler (Flask example):
from stableops import SIGNATURE_HEADER, verify_webhook_signature
@app.route("/hooks/stableops", methods=["POST"])
def handle_webhook():
body = request.get_data(as_text=True)
result = verify_webhook_signature(
body=body,
header=request.headers.get(SIGNATURE_HEADER),
secret=os.environ["STABLEOPS_WEBHOOK_SECRET"],
)
if not result.valid:
return {"error": result.reason}, 401
# Verify against the raw body (don't re-serialize the JSON first).
...
return "", 204During key rotation, pass secrets=[old, new] to accept multiple secrets at once.
Errors
Every non-2xx response is raised as StableOpsError with .status, .code,
.message, and a raw .details body.
from stableops import StableOpsError
try:
client.payment_orders.create(
merchant_order_id="sub_89231_2026_06",
amount="49.00",
accepted_assets=[{"chain": "base", "asset": "USDC"}],
expires_at="2026-06-20T12:30:00Z",
)
except StableOpsError as err:
if err.status == 409:
# Idempotency-key was reused with a different body, etc.
...
raiseNetwork errors (timeouts / connection failures) are also wrapped as
StableOpsError, with status == 0.
How is this guide?
Last updated