Why USDT
USDT (Tether) is the most widely held stablecoin. It's pegged 1:1 to the US dollar, so neither you nor your customer deals with price volatility. Transfer fees are low (a few cents on BSC, more on TRON unless the sender's wallet has energy to cover it), and most crypto users already have USDT in their wallet.
USDT is available as BEP-20 (BSC), TRC-20 (TRON), and ERC-20 (Ethereum). This tutorial uses BSC for the examples, but the same flow works on any supported chain.
Try it without funds first
You can see the whole flow before you touch a wallet. Sign up, confirm your email, and create a test site. Add your webhook URL to it, then press Create test payment and confirm it on the next screen. Your endpoint receives a real payment.confirmed webhook, signed with the site's secret. No addresses, no funds, no charges, and test payments don't use your free allowance.
Once your handler accepts that webhook, come back here and connect a live site. The steps below are the live setup.
What you'll need
- A wallet address you control on BSC, TRON or Ethereum (MetaMask, Trust Wallet, a hardware wallet: anything that gives you the private key)
- An HTTPS endpoint on your server that can receive POST requests (for webhooks)
- Basic familiarity with REST APIs and a tool like
curl
Step 1: Sign up and get your API key
Create an account at app.payhook.app/sign-up. No KYC and no card, just an email and password. Confirm your email from the message we send; sites and API keys unlock once it's confirmed.
Open Sites, create a live site, and copy the API key from its settings. Your API key has the format pk_<hex>. Copy it and store it like a password: anyone with the key can create payments on your account. Keep it server-side; never embed it in client-side JavaScript.
Step 2: Add your wallet addresses
Go to /addresses in the dashboard. For each chain you want to accept payments on, paste the wallet addresses you control.
Each pending payment locks one address until it confirms or expires. If you only have one address and two customers try to pay at the same time, the second payment will fail with a 503 (all addresses held). Add at least five addresses per chain if you expect any concurrent volume.
Step 3: Configure a webhook
Open your site in Sites. Under Webhooks, add your server's webhook URL (e.g. https://yoursite.com/webhooks/payhook) and select the events you want: payment.confirmed and payment.expired.
Save the signing secret shown on the endpoint. You'll need it to verify incoming webhooks. The secret is shown only once. If you lose it, you'll need to rotate it from the dashboard.
Step 4: Create your first payment
curl -X POST https://api.payhook.app/api/v1/payments/ \ -H "X-API-Key: pk_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-123" \ -d '{ "amount_usd_cents": 5000, "network": "bsc", "currency": "usdt", "external_order_id": "order-123", "redirect_url": "https://yoursite.com/orders/123/thanks" }'
The response includes:
checkout_url: redirect your customer here. They see a hosted page with a QR code, deposit address, and countdown timer.assigned_address: the deposit address drawn from your pool.expected_crypto_amount: the exact USDT amount the customer needs to send.expires_at: the payment auto-expires if no on-chain activity is detected by this time (default 30 minutes).
The Idempotency-Key header makes retries safe. If the same key is sent within 24 hours, PayHook returns the original payment instead of creating a duplicate. Derive the key from your order, as above, so a retry of the same order reuses it; a random key generated on each attempt gives no protection.
Step 5: Handle the webhook
When the customer's payment confirms on-chain, PayHook POSTs a JSON body to your webhook URL with an X-PayHook-Signature header. Here's a minimal Node.js handler:
const crypto = require('crypto'); const express = require('express'); const app = express(); app.post('/webhooks/payhook', express.raw({ type: 'application/json' }), (req, res) => { const header = req.headers['x-payhook-signature']; if (typeof header !== 'string') return res.status(401).end(); const parts = Object.fromEntries( header.split(',').map(p => p.split('=').map(s => s.trim())) ); const ts = parseInt(parts.t, 10); if (!ts || !parts.v1 || Math.abs(Date.now() / 1000 - ts) > 300) return res.status(401).end(); const signed = `${ts}.${req.body}`; const expected = crypto .createHmac('sha256', process.env.PAYHOOK_SECRET) .update(signed).digest('hex'); try { if (!crypto.timingSafeEqual( Buffer.from(expected, 'hex'), Buffer.from(parts.v1, 'hex') )) return res.status(401).end(); } catch { return res.status(401).end(); } const event = JSON.parse(req.body); if (event.event === 'payment.confirmed') { // Deliveries can repeat: fulfill only if this order isn't paid yet markPaidOnce(event.external_order_id); } res.status(200).end(); } );
Three things to get right: sign the raw request body (not parsed-then-re-stringified), compare in constant time (timingSafeEqual), and make fulfillment idempotent. A delivery can arrive more than once, for example when your server times out after doing the work, so markPaidOnce should check the order's status inside a transaction before delivering. The docs have verifiers in Python, PHP, Ruby, and Go as well.
What's next
- Monitor webhook deliveries on the dashboard's Webhooks page. Replay any failed delivery with one click.
- Use
GET /api/v1/payments/{payment_number}/to check payment status from your backend. It's a good double-check alongside the webhook. - Read the full API documentation for error handling, billing behavior, and the complete response schema.
- Every account gets 50 free payments every 30 days. Prepaid packs add more, from 9 USDT for 5,000 payments.
Related: How HMAC Webhook Signatures Work ยท How to Accept Crypto Payments on Your Website
Start accepting USDT today.
50 free payments every 30 days. No card required.