PayHook Get started
docs · rest api v1

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

Want to try it first? Create a test site and confirm a sample payment from the dashboard. No wallet addresses or funds needed. The steps below connect a live site.

1

Create a site and copy its API key

Create a merchant account at app.payhook.app/sign-up. Open Sites, create a site, and copy its API key. Keep the key on your server.

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

Open your site in Sites and add your server’s URL under Webhooks. 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:

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: $(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 to one site; rotate the key in your site settings 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.
currencyrequiredstringusdt (the only token monitored today).
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

json POST /api/v1/payments/
{
  "amount_usd_cents": 5000,
  "network": "bsc",
  "currency": "usdt",
  "external_order_id": "order-9837",
  "customer_email": "buyer@example.com",
  "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 it hasn't been paid in full. Observed funds extend the window; an underpayment gets at most 24 hours from creation. Check received on payment.expired.
confirmed_atdatetime
nullable
When confirmations first crossed the threshold.
created_atdatetime—

example response

json 201 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": "buyer@example.com",
  "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 for the same site 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).

Reusing a key with a different request body returns 409 idempotency_key_reused. A retry that arrives while the first request is still being processed returns 409 idempotency_in_flight; wait a moment and retry.

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

json payment.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 a transient 4xx: 408, 425, 429) triggers retries with exponential backoff for about 30 minutes, up to 10 retries. Permanent 4xx (400, 401, 403, 404, 422) gives up immediately.

!

payment.expired doesn't always mean nothing arrived. Its body carries received, the amount that reached your wallet. It is non-zero when a customer underpaid and the partial-payment window ran out; refund or collect the remainder rather than treating the order as unpaid.

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}"), where 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) {
  if (typeof header !== 'string') return false;
  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 is prepaid, in USDT: no card, no Stripe, no fiat onramp, no subscription. Every account gets 50 free payments every 30 days; beyond that, buy payment packs from /topup. For pack prices, see the pricing page. The notes below cover API-visible behavior.

What uses a payment

Confirmed payments only. Payments that expire unpaid, are cancelled, or run in test mode cost nothing. The 50 free payments renew every 30 days and are used first; each confirmed payment after that uses one of the payments you bought. Bought payments never expire.

Running out

When your free payments and bought payments are used up (counting payments still pending), POST /api/v1/payments/ returns 402 plan_limit_reached until you top up or your free payments renew. Payments already open still confirm and their webhooks still deliver. We email you the first time it happens in each 30-day window.

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 your site settings 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 Your free and prepaid payments are used up, counting payments still pending. Top up at /topup, or wait for your free payments to renew. See Billing behavior.
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.