PayOwl API
A non-custodial crypto payments API. You request a deposit address, your customer pays it, and we forward the payment to your wallet and notify your server. New to PayOwl? Start with the overview.
Each deposit address is a small smart contract with your wallet built in: it can only forward what it receives to you, minus the 1% fee. Once a payment is final on-chain, usually within 15–60 seconds, it is forwarded and your server gets a signed callback with the exact amounts.
Base URL: https://api.payowl.io. Every endpoint is a simple GET that returns JSON, and works with or without a trailing slash.
Quickstart
1. Get a permanent USDT (BEP-20) address for your customer 42:
curl "https://api.payowl.io/bep20/usdt/static/?customer_id=42&address=0xYourWallet&callback=https://yourshop.com/payowl/callback%3Fsecret%3DLONG_RANDOM"{
"status": "success",
"address_in": "0x6033bb138E8eC6F375E4b5Ba7F1271729aaac51F", // show this to your customer
"address_out": "0xYourWallet",
"minimum_transaction_coin": "1",
"fee_percent": "1.000",
"ticker": "bep20_usdt",
"type": "static",
"customer_id": "42",
…
}2. Show address_in to your customer (a QR code helps: see /qrcode/).
3. Handle the callback. When the payment is final and forwarded, we call your URL with pending=0. Verify the signature, check your secret, then credit value_forwarded_coin (or value_coin) to customer 42. Reply with any 2xx.
That's the whole integration. The same address keeps working for customer 42 forever; call the endpoint again anytime, and you get the same address back.
Supported coins
Use the ticker in the URL: /{network}/{coin}/…. Payments below the minimum are ignored (see below). GET /info/ always has the current list.
| Ticker | Coin | Network | Contract | Minimum |
|---|---|---|---|---|
bep20/bnb | BNB | BNB Smart Chain | native coin | 0.002 BNB |
bep20/usdt | USDT | BNB Smart Chain | 0x55d3…7955 | 1 USDT |
bep20/usdc | USDC | BNB Smart Chain | 0x8AC7…580d | 1 USDC |
polygon/pol | POL | Polygon PoS | native coin | 10 POL |
polygon/usdt | USDT | Polygon PoS | 0xc213…8e8F | 0.5 USDT |
polygon/usdc | USDC (native) | Polygon PoS | 0x3c49…3359 | 0.5 USDC |
Polygon USDC is native USDC (0x3c49…3359), not the older bridged USDC.e. USDC.e sent to a polygon/usdc address is a different coin and is ignored.
Static addresses
One permanent address per customer: for balance top-ups (games, exchanges, wallets, subscriptions). Any amount, any number of times, never expires.
| Parameter | |
|---|---|
customer_id | Required. Your ID for the customer (up to 200 characters). |
multi_token | 1 = also accept every other supported coin on the same network at this address. Each payment is reported with its own ticker and checked against its own minimum. |
Plus the common parameters: address, callback, pending, convert, post / json. | |
- The same
customer_id+ wallet + callback URL always returns the same address. - Because there are no accounts, the address belongs to that exact combination: changing your callback URL gives your customers new addresses. The old ones keep forwarding to you.
- Callbacks carry
type=staticandcustomer_id.
In your code
Create the address when the customer first opens your deposit page, and store it; asking again returns the same one. URL-encode the callback.
const params = new URLSearchParams({
customer_id: String(user.id),
address: "0xYourWallet",
callback: `https://yourshop.com/payowl/callback?secret=${process.env.PAYOWL_SECRET}`,
pending: "1",
});
const res = await fetch(`https://api.payowl.io/bep20/usdt/static/?${params}`);
const data = await res.json();
if (data.status !== "success") throw new Error(data.error);
// data.address_in: show it to the customer, with data.minimum_transaction_coin<?php
$query = http_build_query([
'customer_id' => $user->id,
'address' => '0xYourWallet',
'callback' => 'https://yourshop.com/payowl/callback?secret=' . getenv('PAYOWL_SECRET'),
'pending' => 1,
]);
$data = json_decode(file_get_contents("https://api.payowl.io/bep20/usdt/static/?$query"), true);
if (($data['status'] ?? '') !== 'success') {
throw new Exception($data['error'] ?? 'PayOwl request failed');
}
// $data['address_in']: show it to the customerimport os, requests
data = requests.get("https://api.payowl.io/bep20/usdt/static/", params={
"customer_id": user.id,
"address": "0xYourWallet",
"callback": f"https://yourshop.com/payowl/callback?secret={os.environ['PAYOWL_SECRET']}",
"pending": 1,
}, timeout=15).json()
if data["status"] != "success":
raise RuntimeError(data["error"])
# data["address_in"]: show it to the customerInvoices
A fresh address for one payment, with an expected amount and an expiry: for checkout.
| Parameter | |
|---|---|
amount | Required. In currency. |
currency | The coin itself (default) or a fiat currency: USD, EUR, GBP… (32 supported). Converted at the current rate, rounded up to 6 decimals. Must come to at least the coin's minimum. |
expires_in | Seconds, 60 to 604800. Default 1800 (30 min). |
order_id | Your order ID. Makes creation idempotent: the same order + wallet + callback returns the same invoice. |
| Plus the common parameters. | |
curl "https://api.payowl.io/bep20/usdt/invoice/?amount=12¤cy=EUR&order_id=INV-1&address=0xYourWallet&callback=https://yourshop.com/payowl/callback"{
"status": "success", "type": "invoice",
"invoice_id": "3f0c…", "order_id": "INV-1", "invoice_status": "waiting",
"address_in": "0x…",
"amount_coin": "13.649409", "currency": "EUR", "amount_requested": "12", "exchange_rate": "0.879159",
"expires_at": "2026-09-24T20:00:00.000Z",
"payment_uri": "ethereum:0x55d3…@56/transfer?address=0x…&uint256=…",
"qr_code": "<base64 PNG>", …
}Show amount_coin, address_in and the QR code (it includes the amount, so wallets like MetaMask and Trust fill it in).
Invoice status
invoice_status | Meaning | Money |
|---|---|---|
waiting | Nothing paid yet | – |
underpaid | Less than the amount so far; the customer can pay the rest | Forwarded to you |
paid | The full amount, in time | Forwarded |
overpaid | More than the amount | Forwarded |
paid_late | Full amount, but completed after the expiry | Forwarded: fulfil or refund at your discretion |
expired | Nothing paid by the expiry | – |
The invoice's current status, amounts (amount_paid_coin, amount_remaining_coin) and its payments. The invoice_id is unguessable; treat it as private.
Common parameters
| Parameter | |
|---|---|
address | Required. Your wallet on that network. Payments are forwarded here. Fixed into the deposit address forever: double-check it. |
callback | Required. Your HTTPS URL for notifications (URL-encode it). Add your own query parameters, e.g. ?secret=…&user=42; they come back in every callback. |
pending | 1 = also notify as soon as a payment is seen, before it's final. |
convert | 1 = add fiat values to callbacks: value_coin_convert and value_forwarded_coin_convert, JSON like {"USD":"99.00","EUR":"87.12",…}. |
post / json | Send callbacks as a form body (post=1) or JSON body (json=1) instead of query parameters. |
These are fixed when the address is created; the response shows the stored values. confirmations and priority are accepted and ignored: we always wait for finality.
Callbacks
| When | |
|---|---|
pending=1 | A payment was seen (only if the address was created with pending=1). Never credit on this. |
pending=0 | The payment is final and forwarded to your wallet. Credit on this one. |
Fields
| Field | |
|---|---|
uuid | This notification. The same on retries: deduplicate on it. |
payment_id | The payment (shared by its pending and confirmed callbacks). |
address_in / address_out | Deposit address / your wallet. |
txid_in | The customer's transaction. |
coin | What was paid, e.g. bep20_usdt (with multi_token it can differ from the address's own ticker). |
value_coin | Amount paid, in the coin. |
price | Coin price in USD at the time (1 for stablecoins). |
pending | 1 or 0, see above. |
Confirmed callbacks (pending=0) also have: | |
txid_out | Our forwarding transaction to your wallet. |
value_forwarded_coin | What reached your wallet: value_coin − fee_coin − network_fee_coin. |
fee_coin | The 1% service fee. |
network_fee_coin | Gas charged (usually 0, see fees). |
confirmations | Blocks since the payment, when forwarded. |
Static addresses: type=static, customer_id. Invoices: type=invoice, invoice_id, order_id, invoice_status, amount_coin, amount_paid_coin, amount_remaining_coin, currency, amount_requested, expires_at, late. | |
Examples
A customer pays 25 USDT to a static address (created with pending=1). Your server gets two callbacks: first pending=1, then pending=0 once the 24.75 USDT has reached your wallet.
# 1) pending: seen, not final yet. Don't credit.
GET https://yourshop.com/payowl/callback?secret=LONG_RANDOM&uuid=73b1b8c3-2cf8-4f8a-a410-186a5fd3a555
&coin=bep20_usdt&type=static&price=1&pending=1
&txid_in=0xa7787be09eae724fc84aeea865394ce241ef6f27b8f705f1cfbd7d99f427de44
&address_in=0x93d98F123be918f618C5A36C05c3B302c7A0E2DF&payment_id=02c2f7cc-cb6f-455b-8fe0-d502df92ad9a
&value_coin=25&address_out=0x7d6C10538E4eDcaEbE3138C977bA6FA6a095F3f9&customer_id=42
# 2) confirmed: final and forwarded to your wallet. Credit now.
GET https://yourshop.com/payowl/callback?secret=LONG_RANDOM&uuid=047bf200-a5e7-4d98-b958-d6ce2c828cf6
&coin=bep20_usdt&type=static&price=1&pending=0
&txid_in=0xa7787be09eae724fc84aeea865394ce241ef6f27b8f705f1cfbd7d99f427de44&fee_coin=0.25
&txid_out=0xb462972299f9004f2e7f961aaf8dff492ed14fc5ca00a1b599359d33b99917b2
&address_in=0x93d98F123be918f618C5A36C05c3B302c7A0E2DF&payment_id=02c2f7cc-cb6f-455b-8fe0-d502df92ad9a
&value_coin=25&address_out=0x7d6C10538E4eDcaEbE3138C977bA6FA6a095F3f9&customer_id=42
&confirmations=51&network_fee_coin=0&value_forwarded_coin=24.75(Line breaks added for reading; it's one URL. Your own parameters, like secret, come first.)
// POST, content-type: application/json. 1) pending
{
"uuid": "a7fa0fa2-0a8a-4d02-acab-71df8fdc8079",
"coin": "bep20_usdt", "type": "static", "price": 1, "pending": 1,
"txid_in": "0xb2d5f479a0458ccc5e92238f06256c7ec62f4fd3f78559ed62d0bf87dac936ff",
"address_in": "0x93d98F123be918f618C5A36C05c3B302c7A0E2DF",
"payment_id": "2e5714a0-c3eb-464a-9759-19b042f203d5",
"value_coin": "25",
"address_out": "0x7d6C10538E4eDcaEbE3138C977bA6FA6a095F3f9",
"customer_id": "42"
}
// 2) confirmed
{
"uuid": "a93ab2c9-aeba-401e-8992-0e9e4eeefd1f",
"coin": "bep20_usdt", "type": "static", "price": 1, "pending": 0,
"txid_in": "0xb2d5f479a0458ccc5e92238f06256c7ec62f4fd3f78559ed62d0bf87dac936ff",
"fee_coin": "0.25",
"txid_out": "0x3e03da0beb119f7b6c934a8a81a8c3713f403fe20fdbda99d8bf245dfd109269",
"address_in": "0x93d98F123be918f618C5A36C05c3B302c7A0E2DF",
"payment_id": "2e5714a0-c3eb-464a-9759-19b042f203d5",
"value_coin": "25",
"address_out": "0x7d6C10538E4eDcaEbE3138C977bA6FA6a095F3f9",
"customer_id": "42",
"confirmations": 51,
"network_fee_coin": "0",
"value_forwarded_coin": "24.75"
}Amounts are strings (exact decimals: parse them with a decimal type, not a float). pending, price and confirmations are numbers. Fields may come in any order.
Delivery
- GET (default): fields as query parameters, added to your callback URL.
post=1: form body.json=1: JSON body. - Reply with any 2xx within 10 seconds. Otherwise we retry after 6 min, 12 min, 24 min… (up to 6 h apart), 12 attempts in total.
- Redirects are not followed, and callbacks only go to public addresses.
- Every attempt is visible in the logs endpoint.
Verifying signatures
Every callback is signed with RSA-SHA256. The signature (base64) is in the x-signature header, and also in x-ca-signature (same value). The signed data is:
- GET callbacks: the full URL we requested, e.g.
https://yourshop.com/payowl/callback?secret=…&uuid=…&… - POST callbacks (
post=1/json=1): the raw request body, exactly as received.
Get the public key once from GET https://api.payowl.io/pubkey/ and store it in your code.
// Express. For POST callbacks use express.raw() / express.text() so you get the exact body.
import crypto from "node:crypto";
const PUBKEY = `-----BEGIN PUBLIC KEY-----
…from https://api.payowl.io/pubkey/…
-----END PUBLIC KEY-----`;
app.get("/payowl/callback", (req, res) => {
const signed = `https://${req.get("host")}${req.originalUrl}`; // full URL as we called it
const sig = Buffer.from(req.get("x-signature") ?? "", "base64");
const ok = crypto.verify("sha256", Buffer.from(signed), PUBKEY, sig);
if (!ok || req.query.secret !== process.env.PAYOWL_SECRET) return res.status(401).end();
if (req.query.pending === "0") {
// dedupe on req.query.uuid, then credit req.query.value_coin to your customer
}
res.send("*ok*");
});<?php
$pubkey = "-----BEGIN PUBLIC KEY-----\n…from https://api.payowl.io/pubkey/…\n-----END PUBLIC KEY-----";
$sig = base64_decode($_SERVER['HTTP_X_SIGNATURE'] ?? '');
$data = $_SERVER['REQUEST_METHOD'] === 'GET'
? 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'] // full URL
: file_get_contents('php://input'); // raw body
if (openssl_verify($data, $sig, $pubkey, OPENSSL_ALGO_SHA256) !== 1) {
http_response_code(401); exit;
}
$p = $_SERVER['REQUEST_METHOD'] === 'GET' ? $_GET : ($_POST ?: json_decode($data, true));
if (($p['secret'] ?? '') !== getenv('PAYOWL_SECRET')) { http_response_code(401); exit; }
if ($p['pending'] == 0) {
// dedupe on $p['uuid'], then credit $p['value_coin']
}
echo '*ok*';# pip install cryptography (Flask example)
import base64, os
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.exceptions import InvalidSignature
PUBKEY = serialization.load_pem_public_key(b"""-----BEGIN PUBLIC KEY-----
...from https://api.payowl.io/pubkey/...
-----END PUBLIC KEY-----""")
@app.route("/payowl/callback", methods=["GET", "POST"])
def payowl_callback():
data = request.url.encode() if request.method == "GET" else request.get_data()
try:
PUBKEY.verify(base64.b64decode(request.headers.get("x-signature", "")),
data, padding.PKCS1v15(), hashes.SHA256())
except (InvalidSignature, ValueError):
return "", 401
p = request.args if request.method == "GET" else (request.get_json(silent=True) or request.form)
if p.get("secret") != os.environ["PAYOWL_SECRET"]:
return "", 401
if p.get("pending") in ("0", 0):
pass # dedupe on p["uuid"], then credit p["value_coin"]
return "*ok*"Behind a proxy or load balancer? The URL your app sees can differ from the one we called (http vs https, host). If verification of GET callbacks fails, rebuild the URL with the public scheme and host, or switch to json=1 and verify the raw body. Also keep a long random secret in your callback URL and check it, as shown.
Payment logs
A static address's payments (newest first, up to 100) with every callback attempt: status, attempts, last error and response. It needs the same three values as /static/, so only you can read it. Handy for "I paid but nothing happened" support tickets.
Go-live checklist
Before accepting real payments, make sure your integration:
| ☐ | Verifies the signature of every callback (how), and rejects the request otherwise. |
| ☐ | Puts a long random secret in the callback URL and checks it. |
| ☐ | Credits only on pending=0. pending=1 is informational: the payment isn't final yet. |
| ☐ | Deduplicates on uuid. Retries resend the same notification; crediting it twice would double the customer's balance. |
| ☐ | Answers with 2xx within 10 seconds. Do slow work (emails, etc.) after replying. |
| ☐ | Credits each payment. A static address can receive many; each has its own payment_id and callbacks. |
| ☐ | Handles amounts as decimals, not floats ("24.75"). |
| ☐ | Shows customers the coin, the network and the minimum, e.g. "USDT on BNB Smart Chain (BEP-20), at least 1 USDT". Payments below the minimum or in another coin are lost. |
| ☐ | Uses a wallet you control in address (MetaMask, a hardware wallet…), valid on that network. Avoid exchange deposit addresses, especially for BNB and POL: we forward from a contract, and many exchanges don't credit native coins sent that way. |
| ☐ | Was tested with one small real payment end to end: address, payment, both callbacks, credit. |
Info, convert, QR codes
| Endpoint | Returns |
|---|---|
GET /info/ | Every ticker with logo, minimum, fee and network-fee estimate. ?prices=1 adds fiat prices. |
GET /{ticker}/info/ | One ticker. |
GET /{ticker}/convert/?value=50&from=EUR | { value_coin, exchange_rate }: fiat (or the coin itself) to coin. |
GET /{ticker}/qrcode/?address=0x…&value=25&size=512 | { qr_code, payment_uri }: base64 PNG. With value, a payment link wallets understand; without, just the address. |
GET /{ticker}/estimate/ | Current forwarding gas cost, and what you'd be charged (usually 0). |
GET /pubkey/ | The public key that signs callbacks. |
GET /logos/{coin}.png | Coin logos (bnb, pol, usdt, usdc), 250×250 PNG. The logo field of /info/ links here. |
<img src="data:image/png;base64,{qr_code}" alt="Payment QR code" width="256" height="256">Fees
| Service fee | 1% of each payment, deducted before forwarding. Fixed into each address when it's created. |
| Network fee | The gas of forwarding, shared across the payments in a batch. If your share is under $0.10 it's free, which on BSC and Polygon it almost always is. It can never exceed 10% of a payment. |
| Signup, monthly, withdrawal | None. |
Every confirmed callback shows exactly what was deducted: fee_coin and network_fee_coin.
Minimums & ignored payments
These payments are not forwarded and are lost for you:
- Below the coin's minimum (see supported coins). Each payment is judged on its own.
- A coin the address doesn't accept, e.g. USDC sent to a USDT address (unless it was created with
multi_token=1), or USDC.e on Polygon.
They get no callback and don't appear in your logs. Tell your customers the exact coin, network and minimum.
Paid on the wrong network? For example, USDT on Polygon to an address created for BSC. That payment is not lost: deposit addresses are the same on every network we support, and it can be recovered manually. Contact support with the deposit address and transaction ID.
FAQ
How long does a payment take?
With pending=1 you hear about it within seconds. The confirmed callback comes once the payment is final on-chain and forwarded to your wallet, usually 15–60 seconds after the customer sends it. An exchange withdrawal can take longer to be sent, depending on the exchange.
The customer sent less (or more) than expected. What happens?
Static addresses have no expected amount: every payment at or above the minimum is forwarded and reported with its exact value_coin. Invoices track it for you: underpaid (the customer can send the rest to the same address), paid, or overpaid. Everything received is forwarded to you either way.
The invoice expired, but the customer paid anyway.
The address keeps working. The payment is forwarded to you and reported as paid_late (or with late=1), so you can decide whether to fulfil the order or refund it.
Can I refund a payment?
The money is already in your wallet, so refunds are a normal transfer from your wallet. Ask the customer for their address; don't send it back to txid_in's sender, which is often an exchange's shared hot wallet.
Can one static address receive several payments?
Yes, any number, forever. Each gets its own payment_id, its own callbacks and its own forward. Customers can save the address like a bank account number.
Do payments from exchanges work?
Yes, as long as the customer withdraws the right coin on the right network (e.g. USDT on "BNB Smart Chain (BEP20)"), sends at least the minimum, and any withdrawal fee doesn't take it below the minimum.
My server was down. Did I lose callbacks?
No. We retry 12 times with growing gaps (6 min, 12 min, 24 min… up to 6 h apart), about a day and a half in total. The money is forwarded to your wallet regardless. You can always see every payment and callback attempt in the logs endpoint.
Can I change my wallet for an existing address?
No. Your wallet is built into the deposit address, which is what makes it safe. Create new addresses with the new wallet (a different address gives a different deposit address). The old addresses keep forwarding to the old wallet, so keep it.
The customer sent the wrong coin, or on the wrong network.
A wrong coin on the right network (e.g. USDC to a USDT address) is ignored and lost. The right coin on the wrong network (e.g. USDT on Polygon to a BSC address) isn't lost: the address exists on both networks and we can recover it on request.
What if PayOwl goes offline?
Payments already forwarded are in your wallet. Anything still at a deposit address can only ever go to your wallet: the contract doesn't allow anything else, and anyone can trigger the forward (see security).
Do I need an account or KYC?
No. There's no signup and no API key. Your wallet address and callback URL are all we need.
Errors & limits
Every error has an HTTP status and a readable message:
{ "status": "error", "error": "callback: callback is required" }| Status | Message (examples) | What to do |
|---|---|---|
400 | callback: callback is required, customer_id: customer_id is required | Add the missing parameter. |
400 | address: address must be a valid EVM address | Your wallet: 0x + 40 hex characters. |
400 | callback must be a public address, callback must be http(s) | Use a public https:// URL: not localhost, not a private IP. |
400 | post and json cannot both be set | Pick one delivery format. |
400 | The invoice must be at least the minimum payment: 1 USDT | Raise the amount (see minimums). |
400 | amount: amount must be a positive number, expires_in: Too small: expected number to be >=60 | Fix the value; use a dot for decimals (12.5). |
400 | Can't price in XYZ. Supported: USDT, USD, EUR, … | Use one of the listed currencies. |
400 / 503 | No BNB price right now, try again shortly | Temporary: market prices are unavailable. Retry, or price the invoice in the coin itself. |
404 | Unknown ticker bep20/doge. See GET /info/ | Check the ticker against supported coins. |
404 | Invoice not found, No static address for this customer_id, wallet and callback | Check the ID, or that all three values match what you used to create the address. |
429 | Too many requests, retry in 23s | Wait, and cache addresses: they never change. |
5xx | Internal server error | Retry with backoff. Payments already sent are never affected. |
Rate limit: 120 requests per minute per IP.
Security & contracts
- We never hold your money. Deposit addresses are contracts that can only forward to the wallet fixed into them. We can't redirect a payment to anyone else.
- Your escape hatch: the factory's
deployAndFlush(orflushon an already-created deposit contract) pushes an address's whole balance, minus the fee, to your wallet. It's public: anyone can call it, you included, even if PayOwl were offline. It needs the address's parameters, which we can give you for any of your addresses. - Callbacks are signed; always verify them.
| Contract | Address (same on BSC and Polygon) |
|---|---|
| ForwarderFactory | 0x4EbDC5F6E162F5765E5413fb4cC59d5B454e7E40 |
| DepositForwarder (implementation) | 0x7FAd5499426c21BA6a819DDD5ccc7F7851Bc055E |
View on BscScan · PolygonScan.