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://.

KeyWhat 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

  1. Your server opens a payment
  2. The customer sends USDT
  3. The network confirms
  4. 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
  }'
FieldMeaning
amountAmount as a string, up to 6 decimals. Minimum 1 USDT
chainOptional. 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_idYour order number, up to 64 characters
success_urlOptional. Where the customer returns 5 seconds after paying. If empty, the return address from your profile settings is used
callback_urlOptional. A different webhook address for this payment
expires_in_minutesOptional. 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

EventMeaningWhat to do
invoice.paidPaid in fullFulfil the order
invoice.paid_latePaid in full after the deadline, added to your balanceFulfil it if you still can, otherwise sort it out with the customer
invoice.underpaidNot paid in full by the deadline, not added to your balanceDon't fulfil it, and contact a Peerway admin
invoice.expiredNo payment arrivedCancel the order
invoice.updatedMore money arrived on a paid invoiceUpdate 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

CodeMeaning
401API key missing or wrong
403The shop is disabled or not approved yet
404Not found, or this payment isn't yours
409order_id was used with other terms, or the balance is too low
422Invalid fields, with the reason in detail
429Too many requests, so try again in a minute

Full request schema: API schema.

Подключите сайт к Peerway

Это руководство для разработчика магазина. В итоге ваш сайт будет открывать страницу оплаты в USDT и сам выполнять заказ, когда платёж поступит.

Разделы

Перед началом

Зарегистрируйтесь. После подтверждения магазина вы получите два ключа в разделе Настройки профиля и укажете там адрес webhook. Адрес должен начинаться с https://.

КлючНазначение
pw_live_...API-ключ: подтверждает ваши запросы
whsec_...Ключ webhook: подтверждает, что сообщение пришло от Peerway

Храните оба ключа только на сервере. Не добавляйте их в код браузера или приложения. Если ключ утёк, сразу замените его в профиле.

USDT работает в двух сетях: BEP-20 (BNB Smart Chain) и Gram (TON). Суммы указываются в USDT, курс вы рассчитываете сами. Монета GRAM как оплата не принимается.

Как проходит платёж

  1. Сервер создаёт платёж
  2. Клиент отправляет USDT
  3. Сеть подтверждает
  4. Сайт получает уведомление

Сервер проверяет подпись и выполняет заказ, а клиент возвращается со страницы оплаты на ваш сайт.

Создание платежа

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
  }'
ПолеЗначение
amountСумма строкой, до 6 знаков после запятой. Минимум 1 USDT
chainНеобязательно. Если не указать, клиент выберет сеть на странице оплаты: это рекомендуемый вариант. Либо задайте bsc или ton. У каждой сети свой баланс
order_idНомер вашего заказа, до 64 символов
success_urlНеобязательно. Адрес, куда клиент вернётся через 5 секунд после оплаты. Если пусто, используется адрес возврата из настроек профиля
callback_urlНеобязательно. Другой адрес webhook для этого платежа
expires_in_minutesНеобязательно. От 5 до 1440, по умолчанию 30

Возьмите checkout_url из ответа и отправьте клиента по этому адресу:

{
  "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 остаётся пустым, пока не поступят деньги. Затем webhook и GET /v1/invoices/{id} покажут, в какой сети прошла оплата. В сети ton клиент отправляет вместе с переводом комментарий, например 1274, и ему нужно немного GRAM для комиссии. Страница оплаты и её QR-код делают это за него.

Если связь прервалась, смело повторите запрос: те же order_id, сумма и сеть вернут тот же платёж. Другая сумма или сеть вернут ошибку 409.

Приём webhook

Когда статус платежа меняется, Peerway отправляет на ваш адрес подписанный POST:

{
  "event": "invoice.paid",
  "invoice": {
    "id": "inv_2675bbc1f6e8ccd977e5d3adde5812ba",
    "order_id": "10482",
    "status": "paid",
    "amount": "25.5",
    "received_amount": "25.5"
  }
}

Не выполняйте заказ, не проверив подпись. Иначе любой сможет отправить на сайт поддельное сообщение об оплате и получить товар бесплатно.

Подпись вычисляется так: HMAC-SHA256(webhook_key, timestamp + "." + raw_body). Время приходит в заголовке X-Peerway-Timestamp, подпись в X-Peerway-Signature. Используйте исходные байты тела до разбора и отклоняйте сообщения старше 5 минут.

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

  • Отвечайте быстро: верните 2xx в течение 10 секунд. Любой другой ответ считается ошибкой.
  • Повторы: неудачное сообщение отправляется снова до 15 раз с растущими интервалами.
  • Возможны дубли: одно сообщение может прийти дважды. Не выполняйте уже выполненный заказ повторно.
  • Без перенаправлений: Peerway не следует за 301 и 302, поэтому указывайте точный адрес.

Статусы

СобытиеЗначениеЧто делать
invoice.paidОплачен полностьюВыполните заказ
invoice.paid_lateОплачен полностью после срока, зачислен на балансВыполните, если возможно, иначе решите вопрос с клиентом
invoice.underpaidНе оплачен полностью в срок, на баланс не зачисленНе выполняйте и обратитесь к администратору Peerway
invoice.expiredПлатёж не поступилОтмените заказ
invoice.updatedНа оплаченный платёж поступили ещё деньгиОбновите received_amount

Если клиент отправит больше суммы, всё равно придёт invoice.paid. Излишек виден в received_amount и зачисляется на баланс.

Статус можно проверить в любой момент: GET /v1/invoices/{id}. Это поможет, если webhook задерживается. Список: GET /v1/invoices?status=paid&page=1.

Проверка подключения

Сначала проверьте без реального платежа. Этот запрос сразу отправляет подписанное тестовое сообщение и возвращает результат:

curl -X POST https://peerway.cc/v1/webhooks/test \
  -H "Authorization: Bearer $PEERWAY_API_KEY" \
  -H "Content-Type: application/json" -d '{}'

Ответ должен быть {"delivered": true, "error": null}. Событие этого сообщения: webhook.test, заказ по нему не выполняйте.

Затем проведите реальный платёж на 1 USDT: отдельной тестовой сети нет. Если клиент платит с биржи, она может удержать комиссию из суммы, и платёж окажется неполным. Страница покажет остаток.

Баланс и вывод средств

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"}'

Минимальная сумма вывода 10 USDT, вывод идёт в той сети, где деньги поступили. Сумма списывается с баланса при запросе и находится в статусе pending, пока её не подтвердит администратор: это не автоматически. Результат виден через GET /v1/withdrawals или в профиле, где заявку тоже можно создать. При отказе сумма вернётся на баланс.

Ошибки

КодЗначение
401API-ключ отсутствует или неверен
403Магазин отключён или ещё не подтверждён
404Не найдено, или этот платёж не ваш
409order_id использован с другими условиями, или недостаточно средств
422Неверные поля, причина в detail
429Слишком много запросов, повторите через минуту

Полная схема запросов: схема API.