PayHook

API documentation

PayHook is a non-custodial crypto payment API. Your customer's funds settle directly to the wallet you control — PayHook watches the chain and POSTs you an HMAC-signed event when a payment confirms. This page covers the full integration in one read.

Quickstart

1

Sign up + grab your API key

Create a merchant account at app.payhook.app/sign-up. Your API key shows up at /api-keys — copy it once and store it like a password.

2

Add receive addresses

For each chain you accept, paste the wallet addresses you control at /addresses. PayHook draws from these when a customer pays — each pending payment locks one address until it confirms or expires, so add enough to cover your peak concurrency.

3

Configure a webhook

At /webhooks, point a URL on your server at PayHook. We POST a signed JSON body when a payment confirms; you reply 2xx. Save the signing secret shown on the endpoint — your verifier needs it, and the secret is shown only once.

4

Create your first payment

One curl. Returns a hosted-checkout URL the customer pays from any wallet:

curlPOST /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: $(uuidgen)" \
  -d '{
    "amount_usd_cents": 5000,
    "network": "bsc",
    "currency": "usdt",
    "external_order_id": "order-123",
    "redirect_url": "https://merchant.com/orders/123/thanks"
  }'

The response includes payment_number, checkout_url, assigned_address, expected_crypto_amount, and expires_at. Send the customer to checkout_url or build your own page using the values.

Authentication

Every request carries an X-API-Key header with your PayHook secret key (format pk_<hex>). Keys are scoped per merchant; rotate at /api-keys if you suspect a leak — the old key 401s immediately.

!

Keep your API key server-side. Never embed it in browser JavaScript, mobile apps, or public repos — anyone with the key can create payments on your account.

Create a payment

POST /api/v1/payments/ X-API-Key required

Creates a payment and returns a hosted-checkout URL. PayHook picks an available address from your pool, locks it for the lifetime of the payment (default 30 minutes), and watches the chain for an inbound transfer of expected_crypto_amount. Returns 201 Created.

Request body

FieldTypeDescription
amount_usd_centsrequiredintegerAmount in USD cents (e.g. 5000 = $50.00).
networkrequiredstringOne of bsc, tron, ethereum.
currencyrequiredstringOne of usdt, usdc, busd, native.
external_order_idoptionalstringYour order ID. Echoed back on every webhook event for this payment.
customer_emailoptionalstringSurfaced on the hosted checkout for receipt-style flows.
redirect_urloptionalstringHosted checkout sends the customer here after confirmation.
metadataoptionalobjectOpaque key/value pairs preserved on the payment and replayed on webhooks.

Example request

jsonPOST /api/v1/payments/
{
  "amount_usd_cents": 5000,
  "network": "bsc",
  "currency": "usdt",
  "external_order_id": "order-9837",
  "customer_email": "[email protected]",
  "redirect_url": "https://merchant.com/orders/9837/thanks",
  "metadata": { "sku": "GOLD-PLAN" }
}

Response body

Returned on create and on every retrieve. The same shape is replayed in the payment.* webhook event body.

FieldTypeDescription
payment_numberstringPublic payment identifier (e.g. PAY-2026-000123). Use this in Retrieve calls and in your own DB.
statusstringOne of pending, confirmed, expired, cancelled, failed.
networkstringEchoed from the request.
currencystringEchoed from the request.
amount_usd_centsintegerEchoed from the request.
expected_crypto_amountstringDecimal — the precise crypto amount the customer must send. USD converted at create time at the live FX rate.
actual_crypto_amountstring
nullable
Decimal — what the chain watcher actually observed. Populated once an inbound transfer is detected, before final confirmations.
assigned_addressstringThe deposit address the customer pays to. Locked to this payment until it terminates.
tx_hashstringOn-chain transaction hash. Empty until the chain watcher spots the transfer.
confirmationsintegerCurrent confirmation count for tx_hash. status flips to confirmed at the chain threshold (BSC 15, ETH 12, TRON 20).
external_order_idstringEchoed from the request.
customer_emailstringEchoed from the request.
redirect_urlstringEchoed from the request.
metadataobjectEchoed from the request.
payment_tokenstring32 url-safe bytes — the credential embedded in checkout_url. Keep this server-side; share checkout_url with your customer.
checkout_urlstringFull https://app.payhook.app/pay/<payment_token> URL. Redirect your customer here.
expires_atdatetimePayment auto-flips to expired at this time if no on-chain activity is detected.
confirmed_atdatetime
nullable
When confirmations first crossed the threshold.
created_atdatetime

Example response

json201 Created
{
  "payment_number": "PAY-2026-000123",
  "status": "pending",
  "network": "bsc",
  "currency": "usdt",
  "amount_usd_cents": 5000,
  "expected_crypto_amount": "50.000000",
  "actual_crypto_amount": null,
  "assigned_address": "0x742d35Cc6634C0532925a3b844Bc9e7595f7E5a3",
  "tx_hash": "",
  "confirmations": 0,
  "external_order_id": "order-9837",
  "customer_email": "[email protected]",
  "redirect_url": "https://merchant.com/orders/9837/thanks",
  "metadata": { "sku": "GOLD-PLAN" },
  "payment_token": "a8f3b2c1...d7e9",
  "checkout_url": "https://app.payhook.app/pay/a8f3b2c1...d7e9",
  "expires_at": "2026-05-24T12:05:00Z",
  "confirmed_at": null,
  "created_at": "2026-05-24T11:35:00Z"
}

Errors

StatusWhen
400No address pool configured for this (network, currency). Add a receive address in the dashboard and retry.
503Every active address is currently held by another pending payment. Retry after a short delay or add more addresses to the pool.

Retrieve a payment

GET /api/v1/payments/{payment_number}/ X-API-Key required

Returns the same response shape as Create, with status, tx_hash, confirmations, actual_crypto_amount, and confirmed_at reflecting the latest on-chain state.

i

Call Retrieve from your webhook handler to verify event payloads server-side before fulfilling the underlying order. A signed webhook is strong, but a Retrieve round-trip is your independent confirmation that PayHook agrees with what the event said.

Cross-merchant lookups return 404 Not Found — a payment number you didn't create looks identical to one that doesn't exist.

Idempotency

Send Idempotency-Key: <opaque> on POST /api/v1/payments/ to make retries safe. A request with the same key from the same merchant within 24 hours returns the original payment instead of creating a duplicate. Use a UUID per logical operation (one per order, not one per HTTP attempt).

The replay reflects live status — if the original payment has confirmed since, the replay returns the confirmed row.

Webhooks

When a payment confirms, PayHook POSTs a JSON body to your endpoint with these headers:

HeaderDescription
Content-Typeapplication/json
X-PayHook-EventEvent type, e.g. payment.confirmed, payment.expired, payment.cancelled.
X-PayHook-Delivery-IdNumeric delivery row identifier — distinct per send (replays get a new ID).
X-PayHook-SignatureHMAC signature in the shape t=<unix_ts>,v1=<hex>. See Verify a signature.

Body shape:

jsonpayment.confirmed
{
  "event": "payment.confirmed",
  "payment_id": "PAY-20260514-ABC123",
  "external_order_id": "order-123",
  "amount": "50.000000",
  "currency": "USDT",
  "chain": "BNB Smart Chain",
  "tx_hash": "0x...",
  "confirmed_at": "2026-05-14T12:34:56+00:00"
}
!

Reply 2xx within 10 seconds. Anything else — 5xx, or transient 4xx (408, 425, 429) — triggers retries with exponential backoff for ~30 minutes (max 10 attempts). Permanent 4xx (400, 401, 403, 404, 422) gives up immediately.

i

Make your handler idempotent on payment_id. Replays from the dashboard create a new delivery row, so your endpoint may receive the same event twice if the original eventually succeeded after a manual replay was queued.

Verify a signature

The signature is HMAC-SHA256(secret, "{timestamp}.{raw_body}") — the timestamp is the integer in the t= field of the header. Reject if it's more than 5 minutes off; that prevents replay of an intercepted event long after the fact.

Sample verifier in your language:

const crypto = require('crypto');

// Express handler — capture raw body via express.raw({ type: 'application/json' }).
function verifyPayhookSignature(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(
    header.split(',').map(p => p.split('=').map(s => s.trim()))
  );
  if (!parts.t || !parts.v1) return false;

  const ts = parseInt(parts.t, 10);
  if (Math.abs(Date.now() / 1000 - ts) > toleranceSec) return false;

  const signed = `${ts}.${rawBody}`;
  const expected = crypto.createHmac('sha256', secret).update(signed).digest('hex');

  try {
    return crypto.timingSafeEqual(
      Buffer.from(expected, 'hex'),
      Buffer.from(parts.v1, 'hex')
    );
  } catch {
    return false;
  }
}
!

Two things to get right.

1. Sign the raw request body bytes. JSON.parse then re-stringify mangles whitespace and breaks the HMAC.

2. Compare in constant time (timingSafeEqual / compare_digest / hash_equals / secure_compare / hmac.Equal). Naive == leaks the prefix-match length via timing.

Billing behavior

PayHook bills via prepaid USDT — no card, no Stripe, no fiat onramp. You top up a balance from /topup; plan renewals and overage settlements debit from that balance. For plan tiers, quotas, and per-event rates, see the pricing page. The notes below cover API-visible behavior — when calls 402, when settlements happen, what insufficient balance means.

What counts toward quota

Confirmed payments only. Pending payments that expire unpaid don't count and don't trigger any fee. The counter is per billing period (30 days for monthly, 365 for yearly) and resets on renewal.

Overage

Once you exceed your plan's included quota, behavior depends on the allow_overage toggle on your /billing page.

  • ON (default for Pro & Scale): additional payments charge per-event from your prepaid balance, settled in batches of 1,000 events — or every 24h, whichever fires first. Predictable batch transactions, not 1,000 individual ledger rows.
  • OFF (default for Free): POST /api/v1/payments/ returns 402 plan_limit_reached until you upgrade or the period renews. Predictable cost, hard ceiling.

Mid-period plan changes

Switching plans mid-period settles any unsettled overage at the OLD plan's rate first (fair: you used those events under the old plan), then debits the new plan price and resets the period to now → +30d/365d. No proration in v1.

Renewal & insufficient balance

Subscriptions auto-renew at period_end. If your balance covers the renewal, we debit + extend. If not, you drop to the Free plan + receive an email; existing in-flight payments keep working, only new POST /payments/ calls hit the Free quota until you top up + upgrade.

Error reference

PayHook returns errors as JSON. Two shapes:

  • Field validation errors: {"errors": {"field_name": ["message", ...]}}
  • Service-level errors: {"error": "human message", "code": "MachineCode"} or {"detail": "..."}
StatusCodeWhen
400 NoAddressConfiguredError Merchant has no active receive addresses for this (network, currency). Add some at /addresses.
400 (field validation) Bad amount, unknown network/currency slug, malformed email/URL, etc.
401 Missing or invalid X-API-Key. Rotate at /api-keys if you've recently changed it.
404 Resource not found, OR the resource belongs to another merchant (we don't disambiguate, to avoid leaking IDs across tenants).
402 plan_limit_reached You're at your plan's included-payments quota AND can't burst (allow_overage is off, OR balance is overdrawn). Upgrade plan, top up, or wait for the period to renew. See Billing behavior.
402 insufficient_balance Returned by POST /api/v1/billing/subscribe/ when your USDT balance can't cover the new plan's debit. Top up at /topup first.
503 all_addresses_held Every active address is currently locked by another pending payment. Transient — retry after one of them confirms or expires, or add more addresses.

Raw OpenAPI schema

For codegen and tooling, the OpenAPI 3.0 JSON spec for the public integration surface is at api.payhook.app/api/v1/schema/. The schema covers Create and Retrieve — dashboard-only operations (webhook setup, billing, branding, address pool, topups) are intentionally omitted; configure those at app.payhook.app.