API Reference
The Cake Merchant API is a RESTful JSON API for accepting Monero payments, managing payouts, running non-custodial swaps, and querying transaction reports. This document covers every public endpoint, the authentication model, the webhook signing recipe, and the conventions the API follows.
The same surface is served on clearnet and on the configured Tor hidden service. No client-side JavaScript is required to use any payment page produced by the API — all customer-facing flows work in Tor Browser Safest.
On this page
- Conventions — base URL, request/response format, dates, money
- Authentication — API keys, JWT, sandbox vs live
- Errors — response shape, status codes
- Idempotency
- Pagination
- Charges
- Payment Requests
- Webhooks — events, signature verification, retries
- Swaps — non-custodial XMR → stablecoin/BTC
- Payouts
- Reports
- Account & Settings
- Price
- Tor /
.onion - Rate limits
- Data retention
- Versioning
- Changelog
Conventions
Base URL: https://your-server.com (your self-hosted instance) or https://<your-onion>.onion for the Tor mirror. The endpoint paths are identical on both.
Content type: Requests with a body must set Content-Type: application/json. Responses are always application/json unless explicitly noted (the CSV report endpoint is the only exception). Request bodies are capped at 10 KB.
Dates: All timestamps are ISO 8601 UTC strings (e.g. 2026-05-20T15:00:00.000Z). Inputs accepting timestamps also accept ISO 8601 with offset; outputs are always UTC.
Money: Amounts are returned as JSON numbers in their natural unit — amount in the listed fiat currency (e.g. 10.00 = $10.00 when currency is USD), and amount_xmr in whole XMR (12 decimal places of precision; piconero = 1e-12 XMR). Amounts must be between 0.01 and 10000000. Treat fiat amounts as fixed-point with 2 decimal places; do not depend on float arithmetic.
Identifiers:
| Prefix | Example | Resource |
|---|---|---|
ch_ | ch_a1b2c3d4e5f6a7b8c9d0e1f2 | Charge (24 hex) |
pr_ | pr_… | Payment request |
po_ | po_a1b2c3d4e5f6a7b8c9d0e1f2 | Payout (24 hex) |
wh_ | wh_… | Webhook |
cm_live_ / cm_test_ | cm_live_… (48 hex) | API key |
Authentication
The API uses two credentials. They are not interchangeable.
| Credential | Header | Used for |
|---|---|---|
API key (cm_live_… / cm_test_…) | x-api-key: cm_live_<48 hex> | Machine-to-machine: charges, payment requests, reports, webhook delivery log, public-side endpoints. Long-lived. |
| JWT (dashboard session) | Authorization: Bearer <jwt> | Sensitive merchant actions: managing webhooks, configuring auto-convert, creating swaps, triggering payouts, changing account settings. Short-lived, issued by the dashboard login flow. |
Why the split? JWTs are dashboard-issued and short-lived; API keys are long-lived secrets stored on a merchant's server. A leaked API key should not be able to move funds (sweep payouts), create swaps, or change destination addresses — those actions require a fresh interactive login. Each endpoint below lists which credential it accepts.
API key format and rotation
API keys are 48 hex characters prefixed with cm_live_ (production) or cm_test_ (sandbox). They are stored hashed at rest and looked up in constant time; full keys are only displayed once at creation or regeneration time.
To rotate a key, call POST /api/account/keys/regenerate with a JWT. The previous key is invalidated immediately.
Sandbox vs live
Sandbox keys (cm_test_…) and live keys (cm_live_…) hit the same endpoint paths. The server selects the live or sandbox wallet based on the key prefix. Use sandbox keys against a wallet configured with a small Monero balance on stagenet or your test mainnet wallet — there is no third-party sandbox; the merchant chooses which wallet backs each key.
CORS
The API allows cross-origin requests from origins listed in CORS_ORIGINS (comma-separated). Methods GET, POST, PUT, DELETE, OPTIONS are allowed; headers Content-Type, Authorization, x-api-key are exposed. Self-hosters should narrow this to their merchant front-end origins.
Errors
All error responses use the same JSON shape:
{
"error": "Amount must be between 0.01 and 10,000,000"
}
The error field is a human-readable string suitable for logging. Programmatic clients should branch on HTTP status, not error text — error strings may be reworded between releases.
Status codes
| Code | Meaning |
|---|---|
200 | Success. |
400 | Bad request — invalid or missing parameter, validation failure. |
401 | Unauthorized — missing or invalid credential. |
403 | Forbidden — credential is valid but lacks permission (suspended account, admin-only endpoint, reporting disabled). |
404 | Resource not found, or not owned by the authenticated merchant. |
410 | Gone — deactivated or expired charge link / payment request. |
423 | Locked — account temporarily locked after repeated failed login attempts. |
429 | Rate limited. The response includes the Retry-After header (seconds). |
500 | Server error. These are logged server-side; retry with backoff. |
Idempotency
The current API does not implement an Idempotency-Key header. To make charge creation safe against retries, set a deterministic identifier on your side and use it in metadata.order_id:
POST /api/charges
{
"amount": 25.00,
"currency": "USD",
"metadata": { "order_id": "cart-7a1c-2026-05-20" }
}
If a request fails with a network error or 5xx, retry with the same body. Before retrying, you may list recent charges and check whether one with that order_id already exists (see List Charges). A first-class Idempotency-Key header is on the roadmap; this section will be updated when it ships.
Pagination
List endpoints that return paginated results accept a limit query parameter. Caps and defaults vary per endpoint and are listed inline below. Where pagination is needed, results are returned newest first; older results can be filtered with date range query parameters where supported (see Reports). A cursor-based pagination scheme is planned for endpoints currently capped at a fixed limit.
Charges
A charge is a single Monero payment intent. Creating a charge allocates a unique subaddress (subaddress_index on the merchant wallet's primary account) and locks a price-equivalent amount_xmr at the moment of creation. The charge expires if not paid within CHARGE_TIMEOUT_MINUTES (default: 60 minutes for API charges, 15 minutes for button charges).
Create charge
POST /api/charges — API key
curl -X POST https://your-server.com/api/charges \
-H "Content-Type: application/json" \
-H "x-api-key: cm_live_abc123" \
-d '{
"amount": 10.00,
"currency": "USD",
"metadata": { "order_id": "12345" }
}'
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
amount | number | yes | 0.01 – 10,000,000 in currency. |
currency | string | no | USD, EUR, or XMR. Default USD. |
metadata | object | no | Arbitrary key/value object. Echoed back on retrieval and in webhooks. Max 10 KB when serialized. Reserved keys: order_id, return_url, description (handled specially — see below). |
Reserved metadata keys
| Key | Behavior |
|---|---|
order_id | Recommended idempotency anchor. See Idempotency. |
return_url | If set to an http:// or https:// URL, the payment page shows a "Return to merchant" button on confirmation. URLs without an http(s) scheme are dropped at creation time. |
description | Free-form text shown to the customer on the payment page. |
Response (200)
{
"id": "ch_abc123def456789",
"merchant_id": "...",
"amount": 10.00,
"currency": "USD",
"amount_xmr": 0.058823529411,
"subaddress": "84Hv16y6x7BTie3ib5Sx...",
"subaddress_index": 7,
"status": "pending",
"confirmations": 0,
"metadata": { "order_id": "12345" },
"created_at": "2026-05-20T15:00:00.000Z",
"expires_at": "2026-05-20T16:00:00.000Z"
}
The charge starts in status: "pending". The lifecycle is described in Webhooks. Once created, redirect the customer to /pay/{id} or display the subaddress + amount_xmr in your own UI.
/pay/{id} page is public by design — anyone with the charge ID can view payment status. The charge ID is 96 bits of randomness, not guessable; treat it as a bearer token for that single payment.Create button charge
POST /api/charges/button — no authentication
Public endpoint designed for embedded payment buttons. Authenticates by the merchant's merchant_id instead of an API key, so the call is safe from a browser. Each merchant is capped at 50 simultaneous pending button charges to prevent abuse; the cap returns 429.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
merchant_id | string | yes (or payment_request_id) | 32-hex merchant identifier from Settings → API Keys. Safe to embed in client-side code. |
amount | number | yes | Same range as POST /api/charges. |
currency | string | no | USD/EUR/XMR. Default USD. |
description | string | no | Sanitized, max 500 chars. |
fields | string[] or csv | no | Field names to collect from the buyer on the payment page (e.g. ["email", "shipping_address"]). Up to 10 fields; each field name max 50 chars, lowercase, [a-z0-9_]. |
expires_at | ISO 8601 | no | Link expiration. See Link expiration. |
expires_in | number | no | Hours from creation. Mutually exclusive with expires_at. |
payment_request_id | string | no | Link this charge to a payment request. Resolves merchant_id if omitted. |
Response is identical to POST /api/charges. Redirect the customer to /pay/{id}.
List charges
GET /api/charges — API key
Returns up to 100 most recent charges for the authenticated merchant. For historical reporting beyond the most-recent 100, use GET /api/v1/reports/transactions with date filters.
{
"charges": [
{
"id": "ch_abc123",
"amount": 10.00,
"status": "confirmed",
"...": "(other charge fields)"
}
]
}
Get charge
GET /api/charges/:id — API key
Returns a single charge owned by the authenticated merchant. Includes metadata and merchant-only fields.
Get charge (public, payment-page view)
GET /api/charges/:id/public — no authentication
Returns a reduced view of a charge — only fields the customer needs to pay. Used by the payment page. merchant_id, raw metadata, and subaddress_index are omitted. The buyer-visible description, sanitized return_url, and link-state fields are included.
Link expiration
The expires_at on the charge itself is the payment timeout — how long the customer has to pay once the page is open. A separate link expiration controls how long the link is valid before anyone visits it. Set link expiration with either:
expires_at— ISO 8601 timestampexpires_in— hours from creation
An expired link returns 410 Gone.
Deactivate / reactivate charge link
POST /api/charges/:id/toggle-active — API key
Toggles the charge link between active and inactive. Inactive links return 410 Gone from the public endpoint. Does not refund or cancel a charge that has already been paid.
Submit buyer info
POST /api/charges/:id/buyer-info — no authentication
Used by the payment page to attach buyer-supplied fields (e.g. email, shipping address) to an in-progress charge. The customer-facing form is server-rendered; this endpoint is the form POST target. Submitted values are stored under metadata.buyer_info on the charge and included in charge.confirmed webhooks.
Get charge QR
GET /api/charges/:id/qr — API key
Returns a QR code for the Monero payment URI.
| Query | Default | Notes |
|---|---|---|
format=png | Binary image/png response. | |
format=dataurl | default | JSON { "dataurl": "data:image/png;base64,..." }. |
Note: PNG is used (not SVG) to remain functional in Tor Browser Safest, which blocks SVG.
Payment requests
A payment request is a shareable link where the price (and optionally buyer fields, expiration, single-use semantics) are fixed up front and a charge is materialized when the link is visited. Use these for invoices, paid signups, and any flow where you want a stable URL.
Create payment request
POST /api/payment-requests — API key
| Field | Type | Required | Notes |
|---|---|---|---|
amount | number | yes | 0.01 – 10,000,000. |
currency | string | no | USD/EUR/XMR. Default USD. |
description | string | no | Max 500 chars, HTML-stripped. |
reference | string | no | Max 100 chars. Your internal identifier. |
single_use | boolean | no | Default true — link is consumed by the first successful payment. |
fields | string[] or csv | no | Buyer fields to collect. Same constraints as button charges. |
expires_at | ISO 8601 | no | Must be in the future. |
List payment requests
GET /api/payment-requests?limit=50 — API key. limit caps at 200.
Get payment request (public)
GET /api/payment-requests/:id/public — no authentication
Returns the buyer-facing view, plus a fresh xmr_price and amount_xmr computed at request time. Returns 410 if inactive, expired, or already paid (single-use).
Toggle payment request
POST /api/payment-requests/:id/toggle — API key. Flips between active and inactive.
Delete payment request
POST /api/payment-requests/:id/delete — API key. Refuses if the request is active or has any completed payments; deactivate first.
Webhooks
Webhooks notify your server when the state of a charge or payout changes. They are HMAC-signed, retried on failure, and may be delivered through Tor for .onion endpoints. Configure webhook URLs from the dashboard or via the API below.
Event catalog
| Event | Fires when | Data shape |
|---|---|---|
charge.created | A charge has been created (via POST /api/charges or button). | Full charge object. |
charge.pending | The Monero network has seen an unconfirmed transaction to the subaddress. | Full charge object with status: "pending" and confirmations: 0. |
charge.confirmed | Charge has reached the required confirmations and is final. | Full charge object with status: "confirmed" and confirmed_at set. |
charge.late_confirmed | An expired charge has been paid after its payment timeout. Same payload as charge.confirmed, distinct event name so you can decide whether to honor late payments. | Full charge object. |
charge.expired | Payment timeout elapsed without enough confirmations. | Full charge object with status: "expired". |
payout.sent | A merchant payout transaction has been broadcast. | { payout_id, amount_xmr, fee_xmr, tx_hash, charge_count } |
Subscribe to the default set (charge.confirmed, charge.expired) or pass an explicit events array when creating a webhook.
Payload shape
Every webhook POST body is a JSON object with this top-level shape:
{
"event": "charge.confirmed",
"data": { "...": "the event-specific payload" },
"timestamp": "2026-05-20T15:01:23.456Z"
}
The timestamp field is the moment the event was emitted (not the moment of delivery — retries reuse the original timestamp). Use it for replay-attack protection (see below).
Signature verification
Each delivery includes a signature header:
X-CAKEMERCHANT-SIGNATURE: <hex>
The signature is HMAC-SHA256(secret, raw_request_body), hex-encoded. The signed input is the raw bytes of the request body, byte-for-byte as received — do not re-serialize the parsed JSON before computing the HMAC, since key ordering and whitespace will differ.
The secret is the value returned by POST /api/webhooks at creation time. It is shown once and stored only encrypted at rest; if you lose it, delete the webhook and recreate it.
Node.js
const crypto = require('crypto');
// Express: capture the raw body so we can hash the exact bytes
app.use('/webhooks/cake', express.raw({ type: 'application/json' }));
app.post('/webhooks/cake', (req, res) => {
const signature = req.header('X-CAKEMERCHANT-SIGNATURE') || '';
const expected = crypto
.createHmac('sha256', process.env.CAKE_WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
const ok = signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!ok) return res.status(401).end();
const payload = JSON.parse(req.body);
// Replay protection: drop events older than 5 minutes
const ageMs = Date.now() - Date.parse(payload.timestamp);
if (ageMs > 5 * 60 * 1000) return res.status(401).end();
handle(payload);
res.status(200).end();
});
Python
import hmac, hashlib, time, json
from datetime import datetime, timezone
from flask import request, abort
SECRET = os.environ['CAKE_WEBHOOK_SECRET'].encode()
@app.post('/webhooks/cake')
def cake_webhook():
sig = request.headers.get('X-CAKEMERCHANT-SIGNATURE', '')
raw = request.get_data() # raw bytes — do not re-serialize
expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
abort(401)
payload = json.loads(raw)
ts = datetime.fromisoformat(payload['timestamp'].replace('Z', '+00:00'))
if (datetime.now(timezone.utc) - ts).total_seconds() > 300:
abort(401)
handle(payload)
return '', 200
PHP
$raw = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_CAKEMERCHANT_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $raw, getenv('CAKE_WEBHOOK_SECRET'));
if (!hash_equals($expected, $sig)) {
http_response_code(401);
exit;
}
$payload = json_decode($raw, true);
$ageSeconds = time() - strtotime($payload['timestamp']);
if ($ageSeconds > 300) {
http_response_code(401);
exit;
}
handle($payload);
http_response_code(200);
Retries
If your endpoint returns a non-2xx status or fails to connect, the delivery is retried up to 3 total attempts with backoff: original delivery, then +5s, then +10s. After the third failure, the delivery is marked permanently failed; the event payload remains queryable via the delivery log.
Your handler should be idempotent — the same event may be delivered multiple times if your 2xx response is lost in transit. Use data.id (the resource identifier) plus event as the de-duplication key.
Delivering over Tor
Set use_tor: true when creating a webhook to route delivery through a SOCKS5 proxy at TOR_SOCKS_URL. This lets a self-hosted instance deliver to a merchant's .onion endpoint without ever reaching the clearnet DNS for that URL. Tor deliveries get a 30-second timeout instead of 10 seconds.
SSRF protection
Webhook URLs are re-resolved at every delivery attempt; if a URL resolves to a private or internal network address (RFC1918, link-local, loopback) the delivery is dropped and logged. This protects against DNS-rebinding attacks where an attacker registers a URL that initially resolves publicly and later flips to 127.0.0.1.
Create webhook
POST /api/webhooks — JWT
POST /api/webhooks
{
"url": "https://yoursite.com/webhooks/cake",
"events": ["charge.confirmed", "charge.expired"],
"use_tor": false
}
Response (200)
{
"id": "wh_...",
"url": "https://yoursite.com/webhooks/cake",
"events": ["charge.confirmed", "charge.expired"],
"secret": "9f0e...64-hex-chars",
"status": "active",
"use_tor": 0,
"created_at": "2026-05-20T15:00:00.000Z"
}
List webhooks
GET /api/webhooks — JWT. Secrets are not returned.
Delete webhook
DELETE /api/webhooks/:id — JWT
Delivery log
GET /api/webhooks/:id/deliveries?limit=50 — API key. Per-webhook delivery history (status code, attempt count, error message). limit caps at 200.
GET /api/webhooks/deliveries?limit=100 — API key. All deliveries across this merchant's webhooks. limit caps at 500.
Swaps
Cake Merchant's swap endpoints are non-custodial. Cake Merchant never holds your funds during a swap — the merchant sends XMR directly to the swap provider's deposit address, and the provider sends the output asset directly to the merchant's specified destination. Cake Merchant only orchestrates quote retrieval and status polling.
Get swap quote
GET /api/swap/quote — no authentication
| Query | Default | Notes |
|---|---|---|
from | xmr | Source ticker. |
to | usdttrc20 | Destination ticker. See Auto-Convert for the full list. |
amount | 1 | Source amount. |
Response
{
"from": "xmr",
"to": "usdttrc20",
"amount": 1,
"quoteId": "...",
"outputAmount": 168.50,
"rate": 170.25,
"provider": "SmallSwap",
"fee": 0.5,
"feePercent": 0.3,
"estimatedTime": "5-30 min",
"minAmount": 0.01,
"maxAmount": 100,
"expiresAt": "2026-05-20T15:10:00.000Z"
}
The upstream quote request is made through Tor with a generic Firefox user-agent so the swap provider cannot link quote traffic to a merchant identity.
GET /api/swap/rate and GET /api/swap/quotes are legacy aliases retained for compatibility with older integrations; new code should call /api/swap/quote.
Create swap
POST /api/swap/create — JWT
Requires a fresh JWT because the response binds a deposit address that will receive funds. Pass quoteId from a recent quote response to lock the rate.
POST /api/swap/create
{
"from": "xmr",
"to": "usdttrc20",
"amount": 1.5,
"address": "TXyz123...",
"quoteId": "..."
}
Response
{
"id": "...",
"swapId": "...",
"provider": "SmallSwap",
"depositAddress": "84Hv...",
"depositMemo": null,
"amountFrom": "1.5",
"amountToExpected": "253.0",
"from": "xmr",
"to": "usdttrc20",
"status": "waiting",
"expiresAt": "2026-05-20T15:30:00.000Z",
"instructions": "Send the specified XMR amount to the deposit address. RP2 does NOT send funds automatically."
}
Get swap status
GET /api/swap/status/:id — JWT. Re-polls the upstream provider through Tor and updates the local record. Status values: waiting, confirming, exchanging, sending, finished, failed, refunded (mapped from the upstream provider's status set).
List swaps
GET /api/swaps — JWT. Returns up to 50 most recent swaps for this merchant.
Payouts
A payout sweeps all confirmed-and-unpaid charges to the merchant's configured Monero payout address in a single transaction. The platform fee (default 0% for self-hosters; configurable per-merchant or by volume tier) is deducted at sweep time.
Get payout balance
GET /api/payouts/balance — JWT
{
"unpaid_charges": 12,
"total_xmr": 5.234,
"fee_rate": 0.01,
"net_xmr": 5.1817,
"has_payout_address": true
}
List payouts
GET /api/payouts — JWT
Sweep payout
POST /api/payouts/sweep — JWT
Builds and broadcasts a Monero transaction with ring size 16 sending the net balance to the configured payout address. Returns 400 if no payout address is configured or there are no unpaid charges. On broadcast failure, marks the payout failed and unmarks the swept charges so the sweep can be retried.
{
"payout_id": "po_...",
"amount_xmr": 5.181766...,
"fee_xmr": 0.0523...,
"tx_hash": "abc...",
"charges_paid": 12
}
A payout.sent webhook is emitted on successful broadcast.
Reports
Reporting must be enabled per-merchant from Settings → Reporting. When enabled, charges are decorated at confirmation time with the fiat value at that moment, so historical reports remain stable as price drifts. When disabled, these endpoints return 403.
Transaction history
GET /api/v1/reports/transactions — API key
| Query | Default | Notes |
|---|---|---|
from | 30 days ago | ISO 8601. |
to | now | ISO 8601. |
format | json | json or csv (downloadable, Content-Disposition: attachment). |
include_fiat | true | Include the snapshotted fiat value and rate used. |
Period summary
GET /api/v1/reports/summary — API key
| Query | Default | Notes |
|---|---|---|
period | monthly | monthly, quarterly, or annual. |
year | current | |
month | current | Required when period=monthly. |
quarter | current | 1–4. Required when period=quarterly. |
Account & settings
Update payout address
PUT /api/account/payout — JWT
{ "address": "4AbC...94-char-monero-address..." }
The address is validated against the Monero address regex (mainnet primary or subaddress, 95 chars). Stored encrypted at rest.
Regenerate API key
POST /api/account/keys/regenerate — JWT
{ "type": "production" } // or "sandbox"
The new key is returned in the response. The old key is invalidated immediately.
Configure auto-convert
PUT /api/merchants/auto-swap — JWT
{
"enabled": true,
"currency": "usdttrc20",
"address": "TXyz..."
}
When enabled, each confirmed charge automatically creates a swap to the configured destination. See Auto-Convert for supported destination currencies and fee model.
DELETE /api/merchants/auto-swap/clear-address — JWT. Clears the destination address; disable in one call without losing the rest of the config.
Other account endpoints
| Endpoint | Auth | Purpose |
|---|---|---|
PUT /api/account/password | JWT | Update password. |
PUT /api/account/email | JWT | Change account email (re-verification required). |
PUT /api/account/profile | JWT | Update business name and contact info. |
PUT /api/account/reporting | JWT | Enable/disable reporting and set reporting_currency. |
POST /api/account/delete | JWT | Delete the account. Requires password confirmation in the body. |
GET /api/auth/2fa/status | JWT | 2FA status. |
POST /api/auth/2fa/setup | JWT | Begin TOTP enrollment. |
POST /api/auth/2fa/verify | JWT | Confirm TOTP code and enable. |
POST /api/auth/2fa/disable | JWT | Disable TOTP (requires current code). |
Price
Get XMR price
GET /api/price/xmr?currency=USD — no authentication
Returns the current XMR/USD or XMR/EUR price from Kraken (direct Kraken pairs only — no third-party aggregator). Useful for displaying a live conversion in a checkout UI without an API key roundtrip.
{
"price": 170.25,
"changePercent24h": 2.5,
"high24h": 175.00,
"low24h": 165.00
}
Tor / .onion
Cake Merchant is designed to be served over Tor as a hidden service in addition to (or instead of) clearnet. The API surface is identical on both.
- Same endpoints, same auth. Every endpoint documented here works against your
.onionhostname. There is no separate Tor-only API. - Onion-Location. When
ONION_URLis configured, every clearnet HTML response advertises the onion mirror via theOnion-Locationheader. Tor Browser will offer to switch. - HSTS and upgrade-insecure-requests are stripped on
.onion— Tor encrypts at the network layer, so the application sees HTTP, and HSTS or scheme upgrades would break access. - Rate-limit identity. The payment-page rate limiter (clearnet: per-IP) keys by session ID on
.onion, because all Tor visitors sharereq.ip = 127.0.0.1from the Tor daemon. The API layer is intended for merchant-server traffic and uses per-IP throughout. - Outbound calls go through Tor. Swap provider quote and status calls route through a SOCKS5 proxy at
TOR_SOCKS_URLwith a generic Firefox user-agent — your merchant identity is not leaked to swap providers via clearnet metadata. - Webhooks can be Tor-delivered. Set
use_tor: trueon a webhook to route delivery through Tor and target a.onionURL. - Tor Browser Safest. Every customer-facing page works with JavaScript disabled and SVG blocked. All API-rendered images (QR codes) are PNG.
Rate limits
| Surface | Limit |
|---|---|
Auth (/api/auth/signup, /api/auth/login) | 10 / 15 min per IP |
Charge creation (POST /api/charges, POST /api/charges/button) | 30 / min |
QR (GET /api/charges/:id/qr) | 60 / min |
| Public charge / payment-request read | 120 / min |
General API (all other /api/* routes) | 500 / min |
Rate-limited responses are 429 with a Retry-After header (seconds). A separate per-merchant cap of 50 simultaneous pending charges applies to POST /api/charges/button.
IPs with 20+ failed logins inside 15 minutes are auto-blocked for 1 hour at the network layer (responses are 403, not 429). Admins can list and manage IP blocks via the admin API.
Data retention
Self-hosted merchants control their own data. With the defaults shipped in this repository:
- Charges keep buyer-supplied fields (collected via
fields) forPRUNE_PHASE1_DAYSdays (default 14), after which those fields are stripped. The charge ID, amount, and confirmation status are kept for ledger continuity. - Charges and webhook delivery logs are fully deleted after
PRUNE_PHASE2_DAYSdays (default 30). - Merchant payout addresses, API keys, webhook secrets, and email addresses are stored encrypted at rest via SQLCipher field-level encryption.
- The pentest report is at PENTEST-REPORT.md; security policy at SECURITY.md.
Versioning
Endpoints in this reference are stable. Reporting endpoints under /api/v1/ are explicitly versioned for forward-compatibility — when a breaking change is needed they will be reissued under /api/v2/ while /v1/ continues to serve. Un-versioned routes will be re-rooted under /api/v1/ in a future release; the un-versioned paths will continue to work as aliases for at least 12 months after that.
Breaking changes are announced in the CHANGELOG with at least 30 days' notice. Additive changes (new endpoints, new optional fields, new event types) are not considered breaking.
Changelog
See CHANGELOG.md for the running list of API changes.