# TrezorPay
> Accept BTC, USDT, USDC, ETH, SOL, LTC, DOGE, BNB and TRX on thirteen networks. TrezorPay forwards every payment straight to your own wallet, minus 1.25%. No account, no API key.
Base URL: https://api.trezorpay.com. Every endpoint is a GET with query parameters and a path ending in "/". No API key: the callback URL identifies a payment address.
# Build with AI
TrezorPay is small enough to integrate in one sitting, and an AI coding agent can do most of it for you. Give it
the prompt below together with a description of your stack (framework, database, how you deploy), and it builds
the client, the callback endpoint, the migrations and the tests.
## What the prompt covers
- Creating payment addresses, QR codes and fiat conversions, with configuration instead of hardcoded wallets.
- Receiving pending and confirmed callbacks in GET, form or JSON form.
- Verifying the RSA-SHA256 `x-ca-signature`, including behind a reverse proxy.
- Idempotency on `uuid` with a database unique constraint, partial payments and overpayments.
- Every error message of the API and which ones to retry.
- A 21-case test suite your agent writes alongside the code: signatures, flows, idempotency, money and client
behaviour.
## Tips
- Append your stack to the prompt, for example: _"Stack: Laravel 11, MySQL, deployed on Forge. Accept USDT on
Tron and USDC on Base."_
- Ask the agent to run the test suite before it reports back, and to expose the callback URL with an HTTPS
tunnel while developing: callbacks to `localhost` are refused.
- The prompt is also served raw at [`/prompt.md`](/prompt.md), and the full documentation as text at
[`/llms-full.txt`](/llms-full.txt), for agents that browse.
# Callbacks
## When callbacks are sent
| Moment | `pending` | Sent when |
| ----------------- | --------- | ---------------------------------------------------- |
| Payment seen | `1` | Only if you created the address with `pending=1` |
| Payment forwarded | `0` | Always, once the amount has been sent to your wallet |
Each payment to an address gets its own pair of callbacks. Addresses never expire.
## Transport
- **GET** (default): every field is added to the query string of your callback URL.
- **POST form** (`post=1`): fields in an `application/x-www-form-urlencoded` body.
- **POST JSON** (`json=1`): fields in a JSON body.
Your own query parameters always stay on the URL.
## Fields
| Field | Description |
| ---------------------- | ----------------------------------------------------------- |
| `uuid` | Payment id, identical on its pending and confirmed callback |
| `address_in` | The address that was paid |
| `address_out` | Your wallet, or the split |
| `txid_in` | Customer's transaction |
| `txid_out` | Our forwarding transaction (confirmed only) |
| `confirmations` | Confirmations at the time of the callback |
| `value_coin` | Amount paid |
| `value_forwarded_coin` | Amount sent to your wallet (confirmed only) |
| `fee_coin` | Our fee (confirmed only) |
| `coin` | Ticker in underscore form, such as `trc20_usdt` |
| `price` | Coin price in USD |
| `pending` | `1` or `0` |
| `result` | `pending` or `sent` |
| `value_coin_convert` | Fiat values, with `convert=1` |
`value`, `value_forwarded` and `fee` are legacy integer fields: read the `_coin` strings instead.
## Retries
Answer **HTTP 200** with the body **`*ok*`** within 30 seconds. Anything else is retried after 6, 12, 24 minutes and
so on, doubling, until the payment is 3 days old. Every attempt is visible in [`/{ticker}/logs/`](/docs/api/payments/get-logs).
## Verifying the signature
Every callback carries `x-ca-signature`: a base64 RSA-SHA256 (PKCS#1 v1.5) signature of the **full URL** for GET
callbacks, or of the **raw body** for POST callbacks. Verify it with the key from [`/pubkey/`](/docs/api/utilities/get-public-key).
```js title="Node.js"
import { createVerify } from 'node:crypto'
export function verify(signedData, signature, pubkey) {
return createVerify('sha256').update(signedData).verify(pubkey, signature, 'base64')
}
```
```python title="Python"
import base64
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
def verify(signed_data: bytes, signature: str, pubkey_pem: str) -> bool:
key = serialization.load_pem_public_key(pubkey_pem.encode())
try:
key.verify(base64.b64decode(signature), signed_data, padding.PKCS1v15(), hashes.SHA256())
return True
except Exception:
return False
```
```php title="PHP"
r.json())
if (body.status !== 'success') throw new Error(body.error)
return body // { address_in, minimum_transaction_coin, ... }
}
// const { address_in } = await createAddress({ ticker: 'trc20/usdt', orderId: 42, wallet: 'T…' })
```
## Receive and verify the callback
```js title="server.js"
import { createVerify } from 'node:crypto'
import express from 'express'
const { pubkey } = await fetch('https://api.trezorpay.com/pubkey/').then((r) => r.json())
const seen = new Set() // use your database in production
const app = express()
app.get('/paid', (req, res) => {
const signed = `https://${req.headers.host}${req.originalUrl}`
const signature = req.headers['x-ca-signature']
const valid = createVerify('sha256').update(signed).verify(pubkey, signature, 'base64')
if (!valid) return res.status(401).end()
const { uuid, pending, order, value_forwarded_coin, coin } = req.query
if (pending === '0' && !seen.has(uuid)) {
seen.add(uuid)
console.log(`order ${order} paid: ${value_forwarded_coin} ${coin} sent to your wallet`)
}
res.send('*ok*')
})
app.listen(3000)
```
Behind a proxy, rebuild the URL exactly as we called it (scheme, host and query string), or the signature will not
match. For `post=1` or `json=1` callbacks, verify the raw request body instead of the URL.
Next: [callback fields](/docs/callbacks) and the [API reference](/docs/api/payments/create-address).
# Accept crypto payments with PHP
Plain PHP 8 with the `openssl` and `curl` extensions; no framework or SDK needed.
## Create a payment address
```php title="create_address.php"
"https://shop.example/paid?order={$orderId}",
'address' => $wallet,
'pending' => 1,
]);
$body = json_decode(file_get_contents("https://api.trezorpay.com/{$ticker}/create/?{$query}"), true);
if (($body['status'] ?? '') !== 'success') {
throw new RuntimeException($body['error'] ?? 'Unknown error');
}
return $body; // address_in, minimum_transaction_coin, ...
}
```
## Receive and verify the callback
```php title="paid.php"
dict:
response = requests.get(
f"{API}/{ticker}/create/",
params={
"callback": f"https://shop.example/paid?order={order_id}",
"address": wallet,
"pending": 1,
},
timeout=15,
)
body = response.json()
if body.get("status") != "success":
raise RuntimeError(body.get("error"))
return body # address_in, minimum_transaction_coin, ...
```
## Receive and verify the callback
```python title="app.py"
import base64
import requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from flask import Flask, request
PUBKEY = serialization.load_pem_public_key(
requests.get("https://api.trezorpay.com/pubkey/", timeout=15).json()["pubkey"].encode()
)
app = Flask(__name__)
def is_signed_by_trezorpay(data: bytes, signature: str) -> bool:
try:
PUBKEY.verify(base64.b64decode(signature), data, padding.PKCS1v15(), hashes.SHA256())
return True
except Exception:
return False
@app.get("/paid")
def paid():
if not is_signed_by_trezorpay(request.url.encode(), request.headers.get("x-ca-signature", "")):
return "", 401
if request.args.get("pending") == "0":
order = request.args["order"]
amount = request.args["value_forwarded_coin"]
# mark the order paid, once per uuid
return "*ok*"
```
`request.url` must be the URL exactly as we called it; behind a proxy, configure Flask's `ProxyFix`. For `post=1` or
`json=1` callbacks, verify `request.get_data()` instead.
# Introduction
TrezorPay accepts crypto payments on Bitcoin, Litecoin, Bitcoin Cash, Dogecoin, Solana, Ethereum, BNB Smart Chain, Polygon, Base,
Arbitrum, Optimism, Avalanche and Tron, and forwards each
one on-chain to the wallet you choose as soon as it confirms. There is no account to open and no API key: one
request gives you a deposit address, and your server is told about every payment through a signed callback.
## How it works
1. **Your server creates an address** with [`/{ticker}/create/`](/docs/api/payments/create-address), passing your
callback URL and your wallet.
2. **Your customer pays it.** If you asked for it, we call your URL with `pending=1` as soon as the payment is seen.
3. **We forward it.** Once the payment has the confirmations its network needs, the amount minus our fee and the
network cost is sent to your wallet.
4. **You get a signed callback** with `pending=0`, the transaction ids and the amounts. Answer `*ok*`.
## Conventions
- Base URL: `https://api.trezorpay.com`
- Every endpoint is a `GET` with query parameters, and every path ends with `/`.
- No API key. The callback URL is the identity of a payment address: the same callback always returns the same address.
- Success answers `200` with `"status": "success"`. Errors answer `400` (or `404`) with `{"status":"error","error":"…"}`.
- Amounts are strings, so no precision is lost.
# Quickstart
## 1. Create an address
Call `/{ticker}/create/` with the URL that should be notified and the wallet that should receive the money. Put
your own order id in the callback URL: it comes back on every callback.
```bash title="Terminal"
curl "https://api.trezorpay.com/trc20/usdt/create/?callback=https%3A%2F%2Fshop.example%2Fpaid%3Forder%3D1042&address=TJzXZQhbhJB3dA2TmxmvyPxk2nKsmZ6yWf&pending=1"
```
```json title="Response"
{
"status": "success",
"address_in": "TN7Zbu4UwtKneYDJyDKXkSorVu95hsE3q4",
"address_out": "TJzXZQhbhJB3dA2TmxmvyPxk2nKsmZ6yWf",
"callback_url": "https://shop.example/paid?order=1042",
"minimum_transaction_coin": "10.00000000",
"priority": "default"
}
```
Show `address_in` to your customer, with a QR code from [`/{ticker}/qrcode/`](/docs/api/utilities/get-qr-code) if you
like. Payments below `minimum_transaction_coin` are recorded but never forwarded.
## 2. Receive the callback
When the payment arrives (with `pending=1`) and again once it has been forwarded, we call your URL:
```text
GET https://shop.example/paid?order=1042&uuid=…&address_in=TN7Z…&address_out=TJzX…
&txid_in=…&txid_out=…&value_coin=250&value_forwarded_coin=244.39&fee_coin=2.5
&coin=trc20_usdt&price=1&pending=0&result=sent
```
## 3. Verify and acknowledge
Check the `x-ca-signature` header with our public key, mark the order paid when `pending=0`, and answer `*ok*`.
```js title="server.js (Express)"
import { createVerify } from 'node:crypto'
import express from 'express'
const { pubkey } = await fetch('https://api.trezorpay.com/pubkey/').then((r) => r.json())
const app = express()
app.get('/paid', (req, res) => {
const url = `https://${req.headers.host}${req.originalUrl}`
const valid = createVerify('sha256')
.update(url)
.verify(pubkey, req.headers['x-ca-signature'], 'base64')
if (!valid) return res.status(401).end()
if (req.query.pending === '0') {
// value_forwarded_coin has reached your wallet: fulfil order req.query.order
}
res.send('*ok*')
})
```
A payment can be delivered more than once (retries). `uuid` is identical on the pending and the
confirmed callback of one payment: store it and ignore repeats.