Payer Endpoints · Geat.io API

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_url the 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.

MethodPathRate limit
GET/v1/pay/{invoice_uuid}5 per second
POST/v1/pay/{invoice_uuid}/select5 per second
GET/v1/pay/{invoice_uuid}/status5 per second
POST/v1/pay/{invoice_uuid}/email5 per second
POST/v1/pay/{invoice_uuid}/report5 per second
GET/v1/pay/report-reasons5 per second
GET/v1/pay/{invoice_uuid}/review10 per second
POST/v1/pay/{invoice_uuid}/review5 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.status is the payment-session status, not the merchant invoice status. The full set is pending (page opened, no coin chosen), waiting, partial, expired, expired_partial, completed, failed, cancelled. Unlike GET /v1/invoices/{invoice_uuid}, the payer surface does not collapse expired_partial into expired - 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_at to the invoice link's validity window (default 24 hours after invoice creation). The short payment clock (default 15 minutes) only starts at /select.
  • ·coins lists 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_data is non-null once a coin is locked, and then coins is reduced to that one coin to prevent client-side bypass. It carries symbol, name, network, icon_url, address, crypto_amount, rate, expires_at, paid_crypto_amount.
  • ·merchant_unavailable is true when the merchant is suspended, inactive or deleted and no coin has been locked yet; coins is then empty. Invoices that already have an address are served normally - the payer may already have sent funds on-chain.
  • ·has_email is true when 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

FieldTypeRequired
coin_symbolstringyes
networkstringno (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_at come 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

StatusCodeWhen
400NOT_INITIALISEDNo payment session exists yet (call the GET first).
400EXPIREDThe payment window has passed.
400INVALID_STATUSThe session is completed, cancelled, failed or expired.
400MISSING_FIELDScoin_symbol absent.
400AMBIGUOUS_COINSymbol available on several networks; specify network.
400COIN_NOT_ACCEPTEDCoin not enabled on this invoice's profile, or inactive.
400GAS_TOO_HIGHL1 Ethereum gas makes this invoice amount uneconomic.
400NO_RATENo usable (non-stale) rate for this coin.
400COIN_NOT_PAYABLENo watch mapping / confirmation threshold, or no active merchant xpub for the chain.
403MERCHANT_UNAVAILABLEMerchant suspended, inactive, deleted - or out of credits. The payer is never told which.
409COIN_LOCKEDA different coin is already locked for this invoice.
409CONFLICTLost a concurrent selection race and the winner's row could not be read back. Retry.
500RATE_ERRORThe crypto amount could not be calculated.
502WATCH_REGISTRATION_FAILEDThe 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 the transactions, partial_expires_at and tx_min_confirmations keys are absent in that early case.
  • ·transactions is populated only while the session is pending or completed. One entry per on-chain transaction id; amount is the sum of that transaction's outputs to the address (8 dp), confirmations is the highest count seen, and status is confirmed once the watch service has reported confirmation, otherwise pending.
  • ·tx_min_confirmations is the coin's confirmation threshold, or null.

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.

StatusCodeWhen
400INVALID_EMAILMissing or malformed address.
409EMAIL_EXISTSAn 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

FieldTypeRequiredRules
turnstile_tokenstringyesTurnstile response token.
reason_idintegeryesAn id from GET /v1/pay/report-reasons.
notestringnoMax 2000 characters.

Success: {"success": true, "message": "Report submitted successfully."}. The reporter's IP is stored with the report.

StatusCodeWhen
400TURNSTILE_FAILEDHuman verification failed. Checked first.
400MISSING_FIELDSreason_id absent.
400INVALID_REASONNo such reason id.
400INVALID_FIELDnote longer than 2000 characters.
409ALREADY_REPORTEDThis 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

FieldTypeRequiredRules
turnstile_tokenstringyesTurnstile response token.
ratingintegeryes1 to 5 inclusive.
commentstringnoMax 2000 characters.

Success: {"success": true, "message": "Review submitted. Thank you!"}. The reviewer's IP is stored with the review.

StatusCodeWhen
400NOT_COMPLETEDThe invoice is not completed. Checked before Turnstile.
400TURNSTILE_FAILEDHuman verification failed.
400INVALID_RATINGRating missing, not an integer, or outside 1-5.
400INVALID_FIELDcomment longer than 2000 characters.
409ALREADY_REVIEWEDA review already exists for this invoice.