Payer endpoints
These endpoints take no API key. They are the public backend of the hosted invoice page: the payer's browser calls them directly, keyed only by the invoice UUID in the URL. They are documented here for completeness - a merchant integration normally uses Invoices and Webhooks instead, and sends the payer to the
invoice_urlthe create call returned.
Because they are public, they are deliberately tight-lipped: they never
leak merchant account state. A merchant who is suspended, inactive or out
of credits surfaces to the payer as MERCHANT_UNAVAILABLE; the real
reason stays in the server log and in the merchant's own notifications.
| Method | Path | Rate limit |
|---|---|---|
| GET | /v1/pay/{invoice_uuid} | 5 per second |
| POST | /v1/pay/{invoice_uuid}/select | 5 per second |
| GET | /v1/pay/{invoice_uuid}/status | 5 per second |
| POST | /v1/pay/{invoice_uuid}/email | 5 per second |
| POST | /v1/pay/{invoice_uuid}/report | 5 per second |
| GET | /v1/pay/report-reasons | 5 per second |
| GET | /v1/pay/{invoice_uuid}/review | 10 per second |
| POST | /v1/pay/{invoice_uuid}/review | 5 per second |
Every one of them answers 404 NOT_FOUND for an unknown or malformed
invoice UUID, a soft-deleted invoice, or an invoice whose profile is
soft-deleted.
GET /v1/pay/{invoice_uuid}
Everything the payment page needs to render.
Response - 200 OK
{
"success": true,
"data": {
"invoice": {
"uuid": "...", "invoice_id": "ORDER-1042",
"fiat_amount": "49.00", "currency": "USD",
"status": "pending",
"created_at": "...", "expires_at": "...",
"success_url": "https://shop.example.com/thanks",
"failure_url": "https://shop.example.com/oops",
"has_email": false
},
"merchant": {
"website_name": "Test Shop",
"website_url": "https://shop.example.com",
"business_type": "E-commerce",
"review_avg": 4.8,
"review_count": 31
},
"coins": [
{
"symbol": "BTC", "name": "Bitcoin", "network": "bitcoin",
"icon_url": "https://.../btc.svg",
"rate": "61234.55",
"crypto_amount": "0.00080021"
}
],
"report_reasons": [{ "id": 1, "reason": "...", "value": "..." }],
"selected_coin_data": null,
"merchant_unavailable": false
}
}
Notes on the non-obvious fields:
- ·
invoice.statusis the payment-session status, not the merchant invoice status. The full set ispending(page opened, no coin chosen),waiting,partial,expired,expired_partial,completed,failed,cancelled. UnlikeGET /v1/invoices/{invoice_uuid}, the payer surface does not collapseexpired_partialintoexpired- it returns the session status verbatim, because the page needs the partial-receipt distinction to render. - ·The merchant-facing invoice status is a separate column with its own set - see invoices.md.
- ·Calling this endpoint creates the payment session row on first use, and
sets its initial
expires_atto the invoice link's validity window (default 24 hours after invoice creation). The short payment clock (default 15 minutes) only starts at/select. - ·
coinslists only active profile coins that have a usable rate. L1 Ethereum coins are filtered out when current gas makes the invoice amount uneconomic. - ·
selected_coin_datais non-null once a coin is locked, and thencoinsis reduced to that one coin to prevent client-side bypass. It carriessymbol,name,network,icon_url,address,crypto_amount,rate,expires_at,paid_crypto_amount. - ·
merchant_unavailableistruewhen the merchant is suspended, inactive or deleted and no coin has been locked yet;coinsis then empty. Invoices that already have an address are served normally - the payer may already have sent funds on-chain. - ·
has_emailistruewhen the invoice carries a customer email or one was submitted on the page.
POST /v1/pay/{invoice_uuid}/select
Lock a coin and issue the payment address. This is the call that claims the merchant's credit, derives the address from the merchant's xpub and registers the watch.
Request body
| Field | Type | Required |
|---|---|---|
coin_symbol | string | yes |
network | string | no (required when the symbol is on several networks) |
Response - 200 OK
{
"success": true,
"data": {
"symbol": "BTC", "name": "Bitcoin", "network": "bitcoin",
"icon_url": "https://.../btc.svg",
"address": "bc1q...",
"crypto_amount": "0.00080021",
"rate": "61234.55",
"expires_at": "2026-10-14T09:27:04.118233+00:00"
}
}
The coin lock covers exactly two session statuses: waiting and
pending, once a coin is selected.
- ·While locked, re-sending the same coin is a pure read: the stored
address, amount, rate and
expires_atcome back unchanged. Nothing is written and the payment clock is not reset. - ·While locked, sending a different coin answers 409
COIN_LOCKED. - ·A session in any other status is not locked. Re-selecting there
refreshes the rate and expected amount, sets the session back to
waiting, and does start a fresh payment window.
crypto_amount is quantized to 8 decimal places, rounded down, at
computation time, so the displayed amount and the amount the backend
expects are byte-identical.
Errors
| Status | Code | When |
|---|---|---|
| 400 | NOT_INITIALISED | No payment session exists yet (call the GET first). |
| 400 | EXPIRED | The payment window has passed. |
| 400 | INVALID_STATUS | The session is completed, cancelled, failed or expired. |
| 400 | MISSING_FIELDS | coin_symbol absent. |
| 400 | AMBIGUOUS_COIN | Symbol available on several networks; specify network. |
| 400 | COIN_NOT_ACCEPTED | Coin not enabled on this invoice's profile, or inactive. |
| 400 | GAS_TOO_HIGH | L1 Ethereum gas makes this invoice amount uneconomic. |
| 400 | NO_RATE | No usable (non-stale) rate for this coin. |
| 400 | COIN_NOT_PAYABLE | No watch mapping / confirmation threshold, or no active merchant xpub for the chain. |
| 403 | MERCHANT_UNAVAILABLE | Merchant suspended, inactive, deleted - or out of credits. The payer is never told which. |
| 409 | COIN_LOCKED | A different coin is already locked for this invoice. |
| 409 | CONFLICT | Lost a concurrent selection race and the winner's row could not be read back. Retry. |
| 500 | RATE_ERROR | The crypto amount could not be calculated. |
| 502 | WATCH_REGISTRATION_FAILED | The watch service could not be reached. Everything is rolled back, including the credit. |
GET /v1/pay/{invoice_uuid}/status
Polling endpoint for the payment page.
Response - 200 OK
{
"success": true,
"data": {
"status": "pending",
"paid_crypto_amount": "0.00080021",
"calculated_crypto_amount": "0.00080021",
"expires_at": "...",
"partial_expires_at": null,
"transactions": [
{ "hash": "abc...", "amount": "0.00080021", "confirmations": 2, "status": "pending" }
],
"tx_min_confirmations": 3
}
}
- ·When no payment session exists yet, the body is
{"status": "pending", "paid_crypto_amount": null, "calculated_crypto_amount": null, "expires_at": null}- note that thetransactions,partial_expires_atandtx_min_confirmationskeys are absent in that early case. - ·
transactionsis populated only while the session ispendingorcompleted. One entry per on-chain transaction id;amountis the sum of that transaction's outputs to the address (8 dp),confirmationsis the highest count seen, andstatusisconfirmedonce the watch service has reported confirmation, otherwisepending. - ·
tx_min_confirmationsis the coin's confirmation threshold, ornull.
POST /v1/pay/{invoice_uuid}/email
Attach a payer email so the receipt can be sent. One email per invoice.
Body: {"email": "payer@example.com"}.
Success: {"success": true, "message": "Email saved."}. If the invoice is
already completed, the receipt is sent immediately.
| Status | Code | When |
|---|---|---|
| 400 | INVALID_EMAIL | Missing or malformed address. |
| 409 | EMAIL_EXISTS | An email was already submitted for this invoice. |
POST /v1/pay/{invoice_uuid}/report
Report an invoice (scam, wrong amount, and so on). Protected by Cloudflare Turnstile. One report per invoice.
Request body
| Field | Type | Required | Rules |
|---|---|---|---|
turnstile_token | string | yes | Turnstile response token. |
reason_id | integer | yes | An id from GET /v1/pay/report-reasons. |
note | string | no | Max 2000 characters. |
Success: {"success": true, "message": "Report submitted successfully."}.
The reporter's IP is stored with the report.
| Status | Code | When |
|---|---|---|
| 400 | TURNSTILE_FAILED | Human verification failed. Checked first. |
| 400 | MISSING_FIELDS | reason_id absent. |
| 400 | INVALID_REASON | No such reason id. |
| 400 | INVALID_FIELD | note longer than 2000 characters. |
| 409 | ALREADY_REPORTED | This invoice has already been reported. |
GET /v1/pay/report-reasons
The report reason list, straight from the database (business data lives in the DB, not in config):
{ "success": true, "data": [{ "id": 1, "reason": "...", "value": "..." }] }
GET /v1/pay/{invoice_uuid}/review
{ "success": true, "data": { "reviewed": false } }
or, when a review exists:
{
"success": true,
"data": {
"reviewed": true,
"rating": 5,
"comment": "Fast.",
"is_auto": false,
"created_at": "..."
}
}
POST /v1/pay/{invoice_uuid}/review
Leave a merchant review. Protected by Cloudflare Turnstile. Completed invoices only, one review per invoice.
Request body
| Field | Type | Required | Rules |
|---|---|---|---|
turnstile_token | string | yes | Turnstile response token. |
rating | integer | yes | 1 to 5 inclusive. |
comment | string | no | Max 2000 characters. |
Success: {"success": true, "message": "Review submitted. Thank you!"}.
The reviewer's IP is stored with the review.
| Status | Code | When |
|---|---|---|
| 400 | NOT_COMPLETED | The invoice is not completed. Checked before Turnstile. |
| 400 | TURNSTILE_FAILED | Human verification failed. |
| 400 | INVALID_RATING | Rating missing, not an integer, or outside 1-5. |
| 400 | INVALID_FIELD | comment longer than 2000 characters. |
| 409 | ALREADY_REVIEWED | A review already exists for this invoice. |