Connect your website to Peerway
This guide is for your shop's developer. By the end, your website opens a USDT payment page and fulfils the order by itself once the payment arrives.
Sections
Before you start
Sign up. Once your shop is approved, you get two keys in the Settings section of your profile and enter your webhook address there. The webhook address must start with https://.
| Key | What it does |
|---|---|
pw_live_... | API key: authorises your requests |
whsec_... | Webhook key: proves a message came from Peerway |
Keep both keys on your server only. Never put them in browser code or an app. If a key leaks, replace it in your profile right away.
USDT works on two networks: BEP-20 (BNB Smart Chain) and Gram (TON). Amounts are in USDT, so you handle any currency conversion yourself. The GRAM coin itself isn't accepted.
How a payment works
- Your server opens a payment
- The customer sends USDT
- The network confirms
- Your website is notified
Your server checks the signature and fulfils the order, and the customer returns from the payment page to your website.
Open a payment
curl -X POST https://peerway.cc/v1/invoices \
-H "Authorization: Bearer $PEERWAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": "25.50",
"order_id": "10482",
"success_url": "https://your-shop.com/thanks",
"expires_in_minutes": 30
}'
| Field | Meaning |
|---|---|
amount | Amount as a string, up to 6 decimals. Minimum 1 USDT |
chain | Optional. Leave it out and the customer picks the network on the payment page, which is what we recommend. Or fix it with bsc or ton. Each network has its own balance |
order_id | Your order number, up to 64 characters |
success_url | Optional. Where the customer returns 5 seconds after paying. If empty, the return address from your profile settings is used |
callback_url | Optional. A different webhook address for this payment |
expires_in_minutes | Optional. From 5 to 1440, default 30 |
Take checkout_url from the response and send the customer there:
{
"id": "inv_2675bbc1f6e8ccd977e5d3adde5812ba",
"order_id": "10482",
"status": "pending",
"chain": null,
"customer_chooses": true,
"token": "USDT",
"address": null,
"memo": null,
"amount": "25.5",
"received_amount": "0",
"checkout_url": "https://peerway.cc/pay/inv_2675bbc1f6e8ccd977e5d3adde5812ba",
"expires_at": "2026-11-01T12:30:00Z"
}
chain stays empty until money arrives. The webhook and GET /v1/invoices/{id} then show which network paid. On ton, the customer sends a comment such as 1274 with the transfer, and needs a little GRAM for the network fee. The payment page and its QR code handle this for them.
If the connection drops, safely repeat the request: the same order_id, amount and chain return the same payment. A different amount or chain returns a 409 error.
Receive webhooks
When a payment's status changes, Peerway sends a signed POST to your address:
{
"event": "invoice.paid",
"invoice": {
"id": "inv_2675bbc1f6e8ccd977e5d3adde5812ba",
"order_id": "10482",
"status": "paid",
"amount": "25.5",
"received_amount": "25.5"
}
}
Never fulfil an order without checking the signature. Otherwise anyone could send your website a fake “paid” message and get the goods for free.
The signature is HMAC-SHA256(webhook_key, timestamp + "." + raw_body). The time comes in the X-Peerway-Timestamp header and the signature in X-Peerway-Signature. Use the raw body bytes from before any parsing, and reject messages older than 5 minutes.
PHP
<?php
$secret = getenv('PEERWAY_WEBHOOK_SECRET');
$rawBody = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_PEERWAY_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_PEERWAY_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300 || !hash_equals($expected, $signature)) {
http_response_code(401);
exit;
}
$event = json_decode($rawBody, true);
if ($event['event'] === 'invoice.paid') {
$orderId = $event['invoice']['order_id'];
// Mark the order paid here. Do nothing if it already is: the same event can arrive twice.
error_log("paid: $orderId");
}
http_response_code(200);Python
import hashlib, hmac, json, os, time
from http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ["PEERWAY_WEBHOOK_SECRET"]
def is_genuine(raw_body: bytes, timestamp: str, signature: str) -> bool:
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
return False
expected = hmac.new(SECRET.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
raw_body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
if not is_genuine(raw_body, self.headers.get("X-Peerway-Timestamp", ""), self.headers.get("X-Peerway-Signature", "")):
self.send_response(401)
self.end_headers()
return
event = json.loads(raw_body)
if event["event"] == "invoice.paid":
order_id = event["invoice"]["order_id"]
# Mark the order paid here. Do nothing if it already is: the same event can arrive twice.
print("paid:", order_id)
self.send_response(200)
self.end_headers()
HTTPServer(("127.0.0.1", 8000), Handler).serve_forever()Node.js
const crypto = require("crypto");
const http = require("http");
const SECRET = process.env.PEERWAY_WEBHOOK_SECRET;
function isGenuine(rawBody, timestamp, signature) {
if (!/^\d+$/.test(timestamp || "") || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = crypto.createHmac("sha256", SECRET).update(`${timestamp}.`).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature || "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
http.createServer((req, res) => {
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
const rawBody = Buffer.concat(chunks);
if (!isGenuine(rawBody, req.headers["x-peerway-timestamp"], req.headers["x-peerway-signature"])) {
res.writeHead(401).end();
return;
}
const event = JSON.parse(rawBody);
if (event.event === "invoice.paid") {
const orderId = event.invoice.order_id;
// Mark the order paid here. Do nothing if it already is: the same event can arrive twice.
console.log("paid:", orderId);
}
res.writeHead(200).end();
});
}).listen(8000, "127.0.0.1");Webhook rules
- Reply quickly: return a 2xx within 10 seconds. Anything else counts as a failure.
- Retries: a failed message is sent again up to 15 times, with growing gaps.
- Duplicates can happen: the same message may arrive twice. Don't fulfil an order that's already done.
- No redirects: Peerway doesn't follow 301 or 302, so give the exact address.
Statuses
| Event | Meaning | What to do |
|---|---|---|
| invoice.paid | Paid in full | Fulfil the order |
| invoice.paid_late | Paid in full after the deadline, added to your balance | Fulfil it if you still can, otherwise sort it out with the customer |
| invoice.underpaid | Not paid in full by the deadline, not added to your balance | Don't fulfil it, and contact a Peerway admin |
| invoice.expired | No payment arrived | Cancel the order |
| invoice.updated | More money arrived on a paid invoice | Update received_amount |
If a customer sends more than the amount, you still get invoice.paid. The extra shows in received_amount and goes to your balance.
You can check a status at any time with GET /v1/invoices/{id}, which helps if a webhook is taking long. For a list: GET /v1/invoices?status=paid&page=1.
Test your integration
Test without a real payment first. This request sends a signed sample message right away and returns the result:
curl -X POST https://peerway.cc/v1/webhooks/test \
-H "Authorization: Bearer $PEERWAY_API_KEY" \
-H "Content-Type: application/json" -d '{}'
The response should be {"delivered": true, "error": null}. This message's event is webhook.test, so don't fulfil an order from it.
Then make a real 1 USDT payment, as there's no separate test network. If a customer pays from an exchange, it may take its fee from the amount so the payment falls short, and the page shows what's left.
Balance and withdrawals
curl https://peerway.cc/v1/balance -H "Authorization: Bearer $PEERWAY_API_KEY"
curl -X POST https://peerway.cc/v1/withdrawals \
-H "Authorization: Bearer $PEERWAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"amount": "100", "chain": "bsc", "address": "0xYOUR_ADDRESS"}'
The minimum withdrawal is 10 USDT, and money leaves on the network it came in on. The amount leaves your balance when you make the request and stays pending until an admin approves it, as this isn't automatic. See the result with GET /v1/withdrawals or in your profile, where you can also make requests. If it's rejected, the amount returns to your balance.
Errors
| Code | Meaning |
|---|---|
401 | API key missing or wrong |
403 | The shop is disabled or not approved yet |
404 | Not found, or this payment isn't yours |
409 | order_id was used with other terms, or the balance is too low |
422 | Invalid fields, with the reason in detail |
429 | Too many requests, so try again in a minute |
Full request schema: API schema.