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.


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:

PrefixExampleResource
ch_ch_a1b2c3d4e5f6a7b8c9d0e1f2Charge (24 hex)
pr_pr_…Payment request
po_po_a1b2c3d4e5f6a7b8c9d0e1f2Payout (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.

CredentialHeaderUsed 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

CodeMeaning
200Success.
400Bad request — invalid or missing parameter, validation failure.
401Unauthorized — missing or invalid credential.
403Forbidden — credential is valid but lacks permission (suspended account, admin-only endpoint, reporting disabled).
404Resource not found, or not owned by the authenticated merchant.
410Gone — deactivated or expired charge link / payment request.
423Locked — account temporarily locked after repeated failed login attempts.
429Rate limited. The response includes the Retry-After header (seconds).
500Server 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

FieldTypeRequiredNotes
amountnumberyes0.01 – 10,000,000 in currency.
currencystringnoUSD, EUR, or XMR. Default USD.
metadataobjectnoArbitrary 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

KeyBehavior
order_idRecommended idempotency anchor. See Idempotency.
return_urlIf 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.
descriptionFree-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.

Never expose API keys client-side. The /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/buttonno 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

FieldTypeRequiredNotes
merchant_idstringyes (or payment_request_id)32-hex merchant identifier from Settings → API Keys. Safe to embed in client-side code.
amountnumberyesSame range as POST /api/charges.
currencystringnoUSD/EUR/XMR. Default USD.
descriptionstringnoSanitized, max 500 chars.
fieldsstring[] or csvnoField 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_atISO 8601noLink expiration. See Link expiration.
expires_innumbernoHours from creation. Mutually exclusive with expires_at.
payment_request_idstringnoLink 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/publicno 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.

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 timestamp
  • expires_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-infono 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.

QueryDefaultNotes
format=pngBinary image/png response.
format=dataurldefaultJSON { "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

FieldTypeRequiredNotes
amountnumberyes0.01 – 10,000,000.
currencystringnoUSD/EUR/XMR. Default USD.
descriptionstringnoMax 500 chars, HTML-stripped.
referencestringnoMax 100 chars. Your internal identifier.
single_usebooleannoDefault true — link is consumed by the first successful payment.
fieldsstring[] or csvnoBuyer fields to collect. Same constraints as button charges.
expires_atISO 8601noMust 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/publicno 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

EventFires whenData shape
charge.createdA charge has been created (via POST /api/charges or button).Full charge object.
charge.pendingThe Monero network has seen an unconfirmed transaction to the subaddress.Full charge object with status: "pending" and confirmations: 0.
charge.confirmedCharge has reached the required confirmations and is final.Full charge object with status: "confirmed" and confirmed_at set.
charge.late_confirmedAn 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.expiredPayment timeout elapsed without enough confirmations.Full charge object with status: "expired".
payout.sentA 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"
}
Save the secret now. It is shown once at creation and stored only encrypted at rest. If you lose it, delete the webhook and create a new one. Maximum 10 webhooks per merchant.

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/quoteno authentication

QueryDefaultNotes
fromxmrSource ticker.
tousdttrc20Destination ticker. See Auto-Convert for the full list.
amount1Source 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

QueryDefaultNotes
from30 days agoISO 8601.
tonowISO 8601.
formatjsonjson or csv (downloadable, Content-Disposition: attachment).
include_fiattrueInclude the snapshotted fiat value and rate used.

Period summary

GET /api/v1/reports/summary — API key

QueryDefaultNotes
periodmonthlymonthly, quarterly, or annual.
yearcurrent
monthcurrentRequired when period=monthly.
quartercurrent1–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

EndpointAuthPurpose
PUT /api/account/passwordJWTUpdate password.
PUT /api/account/emailJWTChange account email (re-verification required).
PUT /api/account/profileJWTUpdate business name and contact info.
PUT /api/account/reportingJWTEnable/disable reporting and set reporting_currency.
POST /api/account/deleteJWTDelete the account. Requires password confirmation in the body.
GET /api/auth/2fa/statusJWT2FA status.
POST /api/auth/2fa/setupJWTBegin TOTP enrollment.
POST /api/auth/2fa/verifyJWTConfirm TOTP code and enable.
POST /api/auth/2fa/disableJWTDisable TOTP (requires current code).

Price

Get XMR price

GET /api/price/xmr?currency=USDno 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 .onion hostname. There is no separate Tor-only API.
  • Onion-Location. When ONION_URL is configured, every clearnet HTML response advertises the onion mirror via the Onion-Location header. 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 share req.ip = 127.0.0.1 from 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_URL with 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: true on a webhook to route delivery through Tor and target a .onion URL.
  • Tor Browser Safest. Every customer-facing page works with JavaScript disabled and SVG blocked. All API-rendered images (QR codes) are PNG.

Rate limits

SurfaceLimit
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 read120 / 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) for PRUNE_PHASE1_DAYS days (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_DAYS days (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.