PayHook Get started

Accept USDT Payments in 30 Minutes

Your first USDT payment: Create, Pay, Confirm.

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

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 POST /api/v1/payments/
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:

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:

Node.js Express webhook 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

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.