free · 0% fees · 60sclaim your page
Developers · API reference

crypto checkout documentation

The crypto checkout documentation for the CRYPT.PE gateway: a Stripe-style payment API covering Bitcoin, Solana, Tron and the Ethereum blockchain — one Ethereum payment API call creates an order, your customer pays from their own wallet, and an HMAC-signed webhook confirms it. Drop-in JS checkout button, SDKs for Node and Python.

Accept payments in ETH, USDT, USDC, BTC and 19 coins without deploying a smart contract or holding keys for anyone: the API only creates payment intents, watches the chain and verifies receipts. Non-custodial — funds settle directly to your wallet.

Accept your first payment in 5 minutes

  1. Sign up at crypt.pe/signup and add your wallet address.
  2. Upgrade to Business ($29/mo) to unlock API keys and the gateway. The personal payment link stays free — only programmatic orders require a paid plan.
  3. Open the dashboard → Gateway card → click new key with test mode on. Save the sk_test_* and whsec_* values.
  4. Copy-paste your stack below — create an order, redirect the customer, verify the webhook:
# 1. create a payment (from your backend)
curl -X POST https://crypt.pe/api/v1/payments \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1001" \
  -d '{
    "amount_usd": 49.99,
    "return_url":  "https://your-site.com/thanks",
    "webhook_url": "https://your-site.com/webhooks/cryptpe",
    "metadata":    { "cart_id": "1001" }
  }'
# → { "order_id": "cp_…", "checkout_url": "https://crypt.pe/checkout/cp_…", … }

# 2. redirect your customer to checkout_url — we handle the rest

# 3. simulate the payment (sandbox key only) and watch your webhook fire
curl -X POST https://crypt.pe/api/v1/payments/cp_.../test-pay \
  -H "Authorization: Bearer sk_test_..."

That's the whole integration. In sandbox, test-pay stands in for the customer's on-chain payment; when you swap in an sk_live_* key, the same code runs against real coins — nothing else changes.

Playground — try it right here

Paste a sandbox key (sk_test_*, from the dashboard Gateway card) and fire a real API payment without leaving this page: create an order, open its hosted checkout, simulate the on-chain payment, watch the status flip to confirmed.

$
your key stays in this browser tab — it's only sent as the Authorization header on your own API calls. test orders never match real payments and live keys are refused by the server.

Payment lifecycle

Every order moves through a small, predictable state machine. Your integration only needs to react to the terminal states — everything before them is handled by the hosted checkout.

pendingpaid_unconfirmedconfirmed|underpaid|overpaid
pending → expired (no payment before expires_at) · pending → cancelled (via API) · confirmed → refunded / partially_refunded
  • pending — order created, customer hasn't paid yet. Cancellable.
  • paid_unconfirmed — transfer seen on-chain, waiting for confirmation depth.
  • confirmed — funds are in your wallet. Fulfil the order. This is the webhook you act on.
  • underpaid — below tolerance; the checkout page shows the remaining delta + QR and confirms once the top-up lands. overpaid — above tolerance; review manually (or enable auto-confirm).
  • expired — quote window closed unpaid; the checkout offers the buyer a "generate fresh quote" re-lock on the same order id.

Authentication

All gateway endpoints use bearer authentication with your sk_live_* key. Never expose the secret key in client-side code. The companion pk_live_* publishable key is reserved for future browser SDKs; today it is not strictly required.

Authorization: Bearer sk_live_<your-secret-key>

Keys are revocable from the dashboard. Revoked keys fail all subsequent requests with HTTP 401.

Payments

POST/api/v1/payments

Create a new order. Returns the order plus a checkout_url you should redirect the customer to.

Optional headers

  • Idempotency-Key — replays with the same key return the original order. The same key with a different body returns 409 idempotency_conflict — a retried timed-out create can never produce two orders.

Request body

{
  "amount_usd":      49,                             // the only required field
  "accepted_coins":  ["eth","usdc-erc20"],           // optional — default: coins enabled on your dashboard
  "return_url":      "https://your-site.com/thanks", // optional — default: dashboard setting
  "webhook_url":     "https://your-site.com/webhooks/cryptpe",
  "customer_email":  "buyer@example.com",            // optional — buyer auto-receives an email receipt (tx hash + explorer link) on confirmation
  "metadata":        { "wc_order_id": 1234 },        // optional
  "expires_in_minutes": 30                            // optional, default 30, max 10080 (7 days)
}

sandbox: create a key with test mode enabled (keys are prefixed sk_test_) — its orders are flagged as test and never match real payments. Simulate a completed payment with POST /api/v1/payments/{order_id}/test-pay — the order flips to confirmed and your webhook receives a normal HMAC-signed payment.confirmed event.

Response

{
  "order_id":     "cp_QxAy6YABSYiIDIDEquW2ir",
  "status":       "pending",
  "amount_usd":   49,
  "accepted_coins": ["eth","matic","usdc-erc20"],
  "expires_at":   "2026-05-30T20:30:00+00:00",
  "checkout_url": "https://crypt.pe/checkout/cp_QxAy6YABSYiIDIDEquW2ir",
  "merchant":     { "username": "...", "display_name": "...", "accent_color": "#EDEDEF" }
}
GET/api/v1/payments/{order_id}

Fetch an order you previously created. Use this to poll status if your webhook handler is offline; webhooks are still the recommended channel.

POST/api/v1/payments/{order_id}/test-pay

Sandbox only. Simulates a completed on-chain payment on a test-mode order: flips it to confirmed, stamps a fake tx hash, and fires a real HMAC-signed payment.confirmed webhook at your handler. Requires an sk_test_* key — live keys and live orders are refused with 403.

GET/api/v1/payments

List and reconcile your orders from your own backend, newest first. Test keys see test orders, live keys see live orders. Query params: status (comma-separated, e.g. confirmed,underpaid), q (matches order id, customer email or tx hash), created_gte / created_lte (ISO timestamps), limit (max 100) and cursor pagination via starting_after=<order_id>. Returns {"object":"list","data":[…],"has_more":bool}.

POST/api/v1/test/payments/{order_id}/simulate

Sandbox only. Drive a test order through any lifecycle stage without touching a chain — perfect for CI. Body: {"action": "detect | confirm | underpay | topup | expire", "amount_pct": 80}. Each action updates the order and fires the corresponding real HMAC-signed webhook (payment.received, payment.confirmed, payment.underpaid, payment.expired) with "test": true in the payload. Requires an sk_test_* key and a test order — live anything is refused with 403.

POST/api/v1/payments/{order_id}/refund

Record a refund. Because CRYPT.PE is non-custodial, you send the refund from your own wallet — this endpoint records it against the order (full or partial) and fires a webhook so your store stays in sync.

{
  "tx_hash":       "0xabc...",   // the refund tx you sent, required
  "amount_crypto": 0.0103,       // optional — omit for a full refund
  "reason":        "customer request"
}

Order status becomes refunded or partially_refunded. Also available from the dashboard UI without an API key.

POST/api/v1/payments/{order_id}/verify-tx

Verify a customer's transaction hash against an order and settle it — the support-ticket escape hatch. If a customer says "I paid but it shows pending/expired", pass their tx hash here: we check it on-chain (recipient address, amount, timing) and, if valid, the order flips to confirmed and your payment.confirmed webhook fires as normal.

{
  "tx_hash": "0xabc..."   // the customer's payment tx, required
}

Expired orders settle too, as long as the tx was mined within the original quote window. Requires the customer to have picked a coin on checkout (that's what locks the address + amount). Also available per-order from the dashboard Gateway tab without an API key.

POST/api/v1/payments/{order_id}/cancel

Cancel a still-pending order and fire payment.cancelled. Once funds may be in flight (paid_unconfirmed and beyond) cancel returns 400 — refund instead.

Coin codes

Values accepted in accepted_coins and select-coin. Which ones are enabled for you depends on the wallet families you've added (EVM address → all EVM coins, etc.).

wallet familycoin codes
evmeth · matic · arb · base · bnb · usdt-erc20 · usdc-erc20 · usdt-bsc · usdc-bsc
btcbtc
solsol · usdc-sol
trontrx · usdt-trc20

these 14 codes are what the gateway API accepts. other assets on crypt.pe (xrp, doge, ltc, ada, ton, sui, apt, dot, bch) are profile / tip-page display coins — payers send wallet-to-wallet, but they can't be used in accepted_coins.

Hosted checkout

The checkout_url we return is a public page that runs end-to-end. You don't normally need to call these endpoints; they exist so you could build a custom checkout UI if you ever wanted one.

GET/api/orders/{order_id}/public

Returns the order without authentication — used by the checkout page. Stale pending orders past expires_at are auto-flipped to expired on read.

POST/api/orders/{order_id}/select-coin

Customer picks which coin to pay in. Locks chosen_coin, chosen_address, and expected_amount_crypto on the order so the webhook matcher has stable values. Each order is quoted a unique crypto amount (a tiny per-order nonce) so an incoming payment binds to exactly one order — even when several orders share the same receiving address and price.

{ "coin": "matic" }

Tips & profile

Build tip buttons and "support me" flows programmatically. POST /v1/tips creates a normal gateway order tagged metadata.kind: "tip" — same hosted checkout, webhooks, tolerance and list API as payments. Optional tipper_name and message land in metadata so you can show a supporter wall.

curl -X POST https://crypt.pe/api/v1/tips \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "amount_usd": 5,
    "tipper_name": "ada",
    "message": "great write-up!",
    "customer_email": "ada@example.com"
  }'
# → { "order_id": "cp_...", "kind": "tip", "checkout_url": "https://crypt.pe/checkout/cp_..." }

GET /v1/profile returns your public profile/tip-page data — every configured wallet rail (including profile-only families like XRP or TON) with a gateway_supported flag, plus the coin codes your key can settle through the API.

curl https://crypt.pe/api/v1/profile -H "Authorization: Bearer sk_live_..."
# → {
#   "username": "satoshi", "profile_url": "https://crypt.pe/satoshi",
#   "rails": [ { "family": "evm", "address": "0x…", "gateway_supported": true,
#                "gateway_coins": ["eth","matic", ...] },
#              { "family": "xrp", "address": "r…", "gateway_supported": false, "gateway_coins": [] } ],
#   "gateway_coins": ["btc","eth", ...], "livemode": true }
  • Tips filter cleanly in reconciliation: GET /v1/payments orders carry metadata.kind = "tip".
  • No-code option: every merchant with a live key gets a hosted tip page at crypt.pe/tip/{username} (prefill with ?amount=5) — grab a copy-paste tip-button snippet from dashboard → settings → promote.
  • Confirmed tips with a name/message appear on the merchant's public profile supporter wall.
  • Profile-only rails (gateway_supported: false) are static receive addresses — link buyers to your profile page for those.
  • If customer_email is set, the tipper is automatically emailed a receipt with the tx hash + explorer link on confirmation.

Webhooks

When an order changes status we POST a JSON event to your webhook_url. Verify every webhook — assume anyone can fire a fake one at your endpoint.

Event types

  • payment.received — the tx was detected on-chain (0-conf). Show "payment detected" to the buyer; don't fulfill yet. Track progress via confirmations / conf_threshold on GET /payments/{id}.
  • payment.confirmed — the on-chain transfer matched the expected amount within your underpaid tolerance (default ±2%, configurable in dashboard → gateway → payment matching, or per order via tolerance_pct on POST /v1/payments). Mark the customer's order as paid.
  • payment.underpaid — less than the expected amount arrived. The hosted checkout stays alive showing the remaining delta + a fresh QR; when the buyer tops up the difference, payment.confirmed fires as normal. Sandbox: simulate with action topup.
  • payment.overpaid — more than expected. Refund the difference manually or absorb it. With auto-confirm-overpaid enabled (dashboard → gateway → payment matching), these fire payment.confirmed instead.
  • payment.expired — the order expired before any payment was received.
  • payment.refunded / payment.cancelled — refund recorded / order cancelled.

Sandbox events carry top-level "test": true — skip fulfillment when present. Unknown event types may be added over time; always return 200 for types you don't handle.

Sample body

{
  "id":      "evt_1706713200_a1b2c3d4",
  "type":    "payment.confirmed",
  "created": "2026-05-30T14:00:00+00:00",
  "data": {
    "order_id":      "cp_QxAy6YABSYiIDIDEquW2ir",
    "status":        "confirmed",
    "amount_usd":    49,
    "coin":          "matic",
    "amount_crypto": 136.111,
    "tx_hash":       "0xabc...",
    "network":       "MATIC_MAINNET",
    "customer_email": "buyer@example.com",
    "metadata":      { "wc_order_id": 1234 }
  }
}

Signature verification

Every request carries an X-Cryptpe-Signature header in the format t=<unix-ts>,v1=<hex>. Re-compute HMAC-SHA256 of "<t>.<raw-body>" with your whsec_* and compare constant-time.

// Node.js using our SDK
const Cryptpe = require('./cryptpe');
const cryptpe = new Cryptpe(process.env.CRYPTPE_SECRET_KEY);

app.post('/webhooks/cryptpe', express.raw({type:'application/json'}), (req, res) => {
  try {
    const event = cryptpe.webhooks.verify(
      req.body,
      req.headers['x-cryptpe-signature'],
      process.env.CRYPTPE_WEBHOOK_SECRET,
    );
    // event.type === 'payment.confirmed' → mark order paid
    res.json({ received: true });
  } catch (e) {
    res.status(400).send('invalid signature');
  }
});
# Python — stdlib only (Flask / FastAPI / Django)
import hmac, hashlib, time, os

def verify_cryptpe(raw_body: bytes, sig_header: str, tolerance: int = 300):
    pairs = [p.split("=", 1) for p in sig_header.split(",")]
    t = next(v for k, v in pairs if k == "t")
    sigs = [v for k, v in pairs if k == "v1"]  # 2 entries during rotation
    if abs(time.time() - int(t)) > tolerance:
        raise ValueError("stale timestamp")
    secret = os.environ["CRYPTPE_WEBHOOK_SECRET"].encode()
    expected = hmac.new(secret, t.encode() + b"." + raw_body,
                        hashlib.sha256).hexdigest()
    if not any(hmac.compare_digest(expected, s) for s in sigs):
        raise ValueError("invalid signature")

# FastAPI usage:
# @app.post("/webhooks/cryptpe")
# async def hook(request: Request):
#     verify_cryptpe(await request.body(),
#                    request.headers["x-cryptpe-signature"])
#     event = await request.json()
#     if event["type"] == "payment.confirmed":
#         ...  # mark order paid
// PHP — matches what our WooCommerce plugin uses
$body = file_get_contents('php://input');
$sig  = $_SERVER['HTTP_X_CRYPTPE_SIGNATURE'];
preg_match('/t=(\d+)/', $sig, $mt);
preg_match_all('/v1=([0-9a-f]+)/', $sig, $mv);   // 2 entries during rotation
$t = $mt[1];
$expected = hash_hmac('sha256', $t.'.'.$body, getenv('CRYPTPE_WEBHOOK_SECRET'));
$ok = false;
foreach ($mv[1] as $v1) { if (hash_equals($expected, $v1)) { $ok = true; break; } }
if (!$ok) { http_response_code(400); exit('bad sig'); }
$event = json_decode($body, true);
// Go — stdlib only
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "errors"
    "os"
    "strconv"
    "strings"
    "time"
)

func VerifyCryptpe(rawBody []byte, sigHeader string) error {
    var ts string
    var sigs []string
    for _, part := range strings.Split(sigHeader, ",") {
        kv := strings.SplitN(part, "=", 2)
        if len(kv) != 2 {
            continue
        }
        switch kv[0] {
        case "t":
            ts = kv[1]
        case "v1": // two entries during secret rotation — accept either
            sigs = append(sigs, kv[1])
        }
    }
    t, err := strconv.ParseInt(ts, 10, 64)
    if err != nil || time.Now().Unix()-t > 300 || t-time.Now().Unix() > 300 {
        return errors.New("stale or missing timestamp")
    }
    mac := hmac.New(sha256.New, []byte(os.Getenv("CRYPTPE_WEBHOOK_SECRET")))
    mac.Write([]byte(ts + "."))
    mac.Write(rawBody)
    expected := hex.EncodeToString(mac.Sum(nil))
    for _, s := range sigs {
        if hmac.Equal([]byte(expected), []byte(s)) {
            return nil
        }
    }
    return errors.New("invalid signature")
}

Rotating your signing secret

Rotate from dashboard → gateway → api keys → rotate (or POST /api/users/me/api-keys/{id}/rotate-webhook-secret). For 24 hours every delivery carries two v1= entries — one signed with the new secret, one with the old — so you can swap the env var on your side without dropping events. Verifiers should accept the payload if any v1 matches (the snippets above already do).

Delivery & retries

Delivery is at-least-once — always dedupe on the event id. We first attempt 3 inline sends (1s, 5s, 25s apart). If your endpoint still hasn't returned a 2xx, a durable retry worker takes over with the schedule 1m → 5m → 30m → 2h → 6h → 24h → 48h → 72h, after which the event is marked permanently failed and you get an email. Non-2xx, timeouts (>10s) and connection errors all count as failures. Every attempt is logged in your dashboard (gateway → webhooks) where you can also redeliver any event instantly.

  • Retried and redelivered events are re-signed with a fresh t= — never cache signatures. Enforce a timestamp tolerance of 300s.
  • Replays carry X-Cryptpe-Replay: true so you can distinguish them in logs.
  • If 5 consecutive deliveries to your endpoint fail, we email you once per 24h with a link to the delivery log.

Firewalls & IP allowlists

Verify signatures — do not IP-filter. Webhooks are not sent from a fixed set of egress IPs, so allowlisting by IP will break your integration. The X-Cryptpe-Signature HMAC is the authoritative proof of origin; with constant-time comparison and the 300s timestamp tolerance it is strictly stronger than any IP check.

Errors

We return standard HTTP status codes and a JSON body with a single detail string.

  • 400 — your request was malformed (missing fields, invalid coins, amount ≤ 0).
  • 401 — missing or invalid Authorization header.
  • 404 — order not found (or you don't own it).
  • 503 — price feed temporarily unavailable. Retry in a few seconds.

Testing

Three ways to test, fastest first:

  • Test webhook button — in the dashboard's Gateway card, fire a sample payment.confirmed at your handler. Signed with your real whsec_*. Once your endpoint answers 2xx, the key shows a green webhook verified badge.
  • test-pay (instant) — create an order with an sk_test_* key, then POST /v1/payments/{order_id}/test-pay to simulate the payment. Try it live in the playground above.
  • Real testnets (full on-chain flow) — orders from sk_test_* keys quote on Sepolia (eth) and BTC testnet (btc) automatically: your EVM address works as-is on Sepolia and your BTC address is re-encoded to its testnet twin. Pay from a testnet wallet with free faucet coins (Sepolia faucet, BTC testnet faucet) — the order confirms from the real chain and your webhook fires exactly like production. Test orders never match mainnet payments.

Health & status

GET/api/v1/ping

Unauthenticated liveness probe for your monitors and cron pre-checks.

{ "ok": true, "time": "2026-06-06T18:00:00Z", "service": "cryptpe-gateway" }

Component-level health (per-chain rails, webhook success rate, 30-day uptime) lives on the public status page at /status — JSON at GET /api/status.

SDKs & plugins

Operator: HD wallets (self-hosted)

Self-hosting CRYPT.PE? Configure HD-derived receiving addresses so every order gets its own on-chain destination — auto-detect becomes an unambiguous address → order_id lookup instead of a fuzzy amount-and-time match. Two trust modes are supported; pick whichever matches your operations posture.

self-hosted deployments are operated entirely at your own risk — you control the keys, the infrastructure and the funds; crypt.pe never has access to either. see the non-custodial disclaimer.

Mode A — xpub-only (recommended)

The host receives only an extended public key per rail. It can derive every incoming-funds address forever, but cannot move funds. The matching private key stays on a hardware wallet or air-gapped machine — that's the machine you use when it's time to sweep.

Derive each xpub at the change-chain level (one above the address index) and paste into the env var listed below.

# Each xpub covers all non-hardened children — i.e. /0, /1, /2, …
# Derive AT the change-chain level (NOT at the address-index level).

CRYPTPE_EVM_XPUB="xpub6D…"   # path: m/44'/60'/0'/0   (Ethereum / Polygon / Base / Arbitrum)
CRYPTPE_BTC_XPUB="zpub6t…"   # path: m/84'/0'/0'/0    (Bitcoin native segwit, bc1q…)
CRYPTPE_TRON_XPUB="xpub6…"   # path: m/44'/195'/0'/0  (Tron, base58 T…)
How to export from common wallets
  • Sparrow Wallet (BTC): Settings → Keystores → Master Public Key. Choose script type P2WPKH (Native SegWit) → copy the zpub….
  • Electrum (BTC): Wallet → Information → Master Public Key. Create the wallet as Native SegWit. Copy the zpub….
  • Ledger / Trezor (BTC): use Sparrow or Electrum in watch-only mode against the hardware wallet → export the zpub from there. Keeps the seed on-device.
  • EVM (Ethereum + L2s): MetaMask doesn't export xpubs directly. Derive the account-level xpub on an offline signer you already trust — a hardware wallet's own companion tooling (Ledger Live / Trezor Suite), or a dedicated air-gapped machine that never touches the internet again. Never paste a seed into any website or browser tool.
  • Tron: same rule — derive the account-level xpub… with offline, hardware-backed tooling; the seed itself must never leave the signing device.

Mode B — mnemonic (local development only, never production)

Do not use this mode with real funds. A mnemonic in an env var is a single-host compromise path to every derived address — logs, backups, crash reports, shell history and CI variables can all leak it, and on-chain theft is irreversible. Production deployments must be watch-only: xpub / public descriptors only (Mode A).

For local development against testnets, you may set a throwaway test seed (one generated for this purpose, never one that holds or will hold funds):

# testnet development only — throwaway seed, never a real wallet
CRYPTPE_HD_MNEMONIC="word1 word2 … word12"

xpub always takes priority per rail: if both CRYPTPE_EVM_XPUB and CRYPTPE_HD_MNEMONIC are set, the EVM rail uses the xpub. Before going live, remove CRYPTPE_HD_MNEMONIC entirely and configure every mainnet rail from xpubs. Keep signing and sweeping on hardware or offline systems — the payment host should never be able to spend.

Sweep checklist

  1. Open /admin/cohorts → the hd addresses table shows the next derivation index + preview address per rail.
  2. On your air-gapped machine, derive the same index from the master seed and confirm the address matches. If it doesn't, stop — the host's xpub is wrong.
  3. Sweep the funds. Addresses below next_index are the ones that have been handed out; anything still holding a balance is fair game.

about crypt.pe

01

non-custodial by design

Payments settle wallet-to-wallet on-chain, straight from the payer to your own wallet. crypt.pe never holds, freezes or forwards funds — there is no platform balance and no withdrawal step, and every payment gets a verifiable on-chain receipt.

02

0% transaction fees

Plans are flat subscriptions with a free tier — compare that with the 1–2% charged by custodial processors. One page accepts Bitcoin, USDT, Ethereum, Solana and 19 coins across 13 networks, with simple pricing and no payout schedule.

03

tools merchants actually use

Exact-amount invoices with live tracking, product links, printable QR standees, HMAC-signed webhooks, CSV exports and a Stripe-style API — see the merchant guides or create your free page in about a minute.

04

how a payment works

You add wallet addresses you already own, share your crypt.pe link or QR code, and the customer pays from their own wallet. crypt.pe locks the amount at invoice time, watches the chain, matches the transaction and issues a receipt both sides can verify on a block explorer — software around the payment, never in the money flow.

05

available wherever wallet-to-wallet payments are permitted

Because settlement is on-chain to your own wallet, there is no bank partnership gating access and no account that can be closed over geography — local crypto rules still apply. Merchants use crypt.pe across India, the UAE, Nigeria, the Philippines, Brazil and 100+ other markets — see the country guides.

06

stablecoin-first, volatility optional

Accept USDT or USDC and a $100 invoice locks to a fixed token amount that targets a $1 peg — value movement between creation and payment stays minimal. Prefer BTC, ETH or SOL? Token amounts are locked at invoice time either way, and your dashboard records the USD value of every payment for clean bookkeeping. Questions? Start with the FAQ or payment help.