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
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.
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.
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.
Create your first payment
One curl. Returns a hosted-checkout URL the customer pays from any wallet:
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
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
| Field | Type | Description |
|---|---|---|
| amount_usd_centsrequired | integer | Amount in USD cents (e.g. 5000 = $50.00). |
| networkrequired | string | One of bsc, tron, ethereum. |
| currencyrequired | string | One of usdt, usdc, busd, native. |
| external_order_idoptional | string | Your order ID. Echoed back on every webhook event for this payment. |
| customer_emailoptional | string | Surfaced on the hosted checkout for receipt-style flows. |
| redirect_urloptional | string | Hosted checkout sends the customer here after confirmation. |
| metadataoptional | object | Opaque key/value pairs preserved on the payment and replayed on webhooks. |
Example request
{
"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.
| Field | Type | Description |
|---|---|---|
| payment_number | string | Public payment identifier (e.g. PAY-2026-000123). Use this in Retrieve calls and in your own DB. |
| status | string | One of pending, confirmed, expired, cancelled, failed. |
| network | string | Echoed from the request. |
| currency | string | Echoed from the request. |
| amount_usd_cents | integer | Echoed from the request. |
| expected_crypto_amount | string | Decimal — the precise crypto amount the customer must send. USD converted at create time at the live FX rate. |
| actual_crypto_amount | string nullable | Decimal — what the chain watcher actually observed. Populated once an inbound transfer is detected, before final confirmations. |
| assigned_address | string | The deposit address the customer pays to. Locked to this payment until it terminates. |
| tx_hash | string | On-chain transaction hash. Empty until the chain watcher spots the transfer. |
| confirmations | integer | Current confirmation count for tx_hash. status flips to confirmed at the chain threshold (BSC 15, ETH 12, TRON 20). |
| external_order_id | string | Echoed from the request. |
| customer_email | string | Echoed from the request. |
| redirect_url | string | Echoed from the request. |
| metadata | object | Echoed from the request. |
| payment_token | string | 32 url-safe bytes — the credential embedded in checkout_url. Keep this server-side; share checkout_url with your customer. |
| checkout_url | string | Full https://app.payhook.app/pay/<payment_token> URL. Redirect your customer here. |
| expires_at | datetime | Payment auto-flips to expired at this time if no on-chain activity is detected. |
| confirmed_at | datetime nullable | When confirmations first crossed the threshold. |
| created_at | datetime | — |
Example response
{
"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
| Status | When |
|---|---|
| 400 | No address pool configured for this (network, currency). Add a receive address in the dashboard and retry. |
| 503 | Every active address is currently held by another pending payment. Retry after a short delay or add more addresses to the pool. |
Retrieve a payment
Returns the same response shape as Create, with status, tx_hash, confirmations, actual_crypto_amount, and confirmed_at reflecting the latest on-chain state.
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:
| Header | Description |
|---|---|
| Content-Type | application/json |
| X-PayHook-Event | Event type, e.g. payment.confirmed, payment.expired, payment.cancelled. |
| X-PayHook-Delivery-Id | Numeric delivery row identifier — distinct per send (replays get a new ID). |
| X-PayHook-Signature | HMAC signature in the shape t=<unix_ts>,v1=<hex>. See Verify a signature. |
Body shape:
{
"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.
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; } }
import hmac, hashlib, time def verify_payhook_signature(raw_body: bytes, header: str, secret: str, tolerance_sec: int = 300) -> bool: parts = {} for piece in header.split(","): k, _, v = piece.partition("=") if k and v: parts[k.strip()] = v.strip() if "t" not in parts or "v1" not in parts: return False try: ts = int(parts["t"]) except ValueError: return False if abs(int(time.time()) - ts) > tolerance_sec: return False signed = f"{ts}.".encode() + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, parts["v1"])
<?php function verify_payhook_signature(string $rawBody, string $header, string $secret, int $toleranceSec = 300): bool { $parts = []; foreach (explode(',', $header) as $piece) { [$k, $v] = array_pad(explode('=', $piece, 2), 2, ''); if ($k !== '' && $v !== '') $parts[trim($k)] = trim($v); } if (!isset($parts['t'], $parts['v1'])) return false; $ts = (int)$parts['t']; if (abs(time() - $ts) > $toleranceSec) return false; $signed = $ts . '.' . $rawBody; $expected = hash_hmac('sha256', $signed, $secret); return hash_equals($expected, $parts['v1']); }
require 'openssl' def verify_payhook_signature(raw_body, header, secret, tolerance_sec: 300) parts = header.split(',').map { |p| p.split('=', 2).map(&:strip) }.to_h return false unless parts['t'] && parts['v1'] ts = parts['t'].to_i return false if (Time.now.to_i - ts).abs > tolerance_sec signed = "#{ts}.#{raw_body}" expected = OpenSSL::HMAC.hexdigest('sha256', secret, signed) # OpenSSL.fixed_length_secure_compare ships in stdlib (Ruby 2.5+); no Rack dep. expected.bytesize == parts['v1'].bytesize && OpenSSL.fixed_length_secure_compare(expected, parts['v1']) end
package payhook import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "strconv" "strings" "time" ) func VerifySignature(rawBody []byte, header, secret string, tolerance time.Duration) bool { parts := map[string]string{} for _, p := range strings.Split(header, ",") { kv := strings.SplitN(p, "=", 2) if len(kv) == 2 { parts[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1]) } } ts, err := strconv.ParseInt(parts["t"], 10, 64) if err != nil || parts["v1"] == "" { return false } if diff := time.Since(time.Unix(ts, 0)); diff > tolerance || diff < -tolerance { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(strconv.FormatInt(ts, 10) + ".")) mac.Write(rawBody) expected := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(expected), []byte(parts["v1"])) }
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/returns402 plan_limit_reacheduntil 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": "..."}
| Status | Code | When |
|---|---|---|
| 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.