Crypto Payment Gateway API Integration: Payments, Webhooks, Payouts

A crypto payment gateway API is an HTTP interface your backend uses to create invoices, read payment status, and send payouts, with no plugin sitting between your code and the processor. This guide is for a developer or technical founder who has already decided to accept crypto and wants a reproducible Speend integration rather than a provider comparison. You get the real endpoints, webhook signature verification, the webhook status model, and payouts, and you can check every example against the WooCommerce plugin, which ships the same client code.

This guide picks up after you’ve chosen to accept crypto at all; for that decision, see how to accept crypto payments.

Plugin, hosted page, link, or API: which to choose

Choose by how much control you need against how much you want to build. Four connection methods, from most ready to most flexible: a ready-made plugin, a payment link, a dynamic API invoice, and static API wallets. Reach for the API when you run your own checkout, your own backend, or a platform without a plugin.

A plugin is the fastest path if your store runs on a supported platform. Speend ships ready-made plugins for WooCommerce, OpenCart, WHMCS, PrestaShop, and XenForo; the WooCommerce plugin installs in under two hours and syncs order status over webhooks for you. See the plugin section and the WooCommerce plugin page. If your platform isn’t among the ready-made plugins, the API is the route.

A payment link or hosted invoice needs no code and suits one-off or low-volume billing. You create the invoice, hand the customer a URL, and read the result later.

A dynamic API invoice is the core of the integration: your backend calls createPayment and gets back a fresh payment address, a QR code, and the exact payer_amount in the payer’s currency. You render the checkout and confirm from webhooks. Full control, no hosted page in the loop.

Static API wallets hand you one reusable address per customer or cause through createStaticWallet, instead of a fresh invoice each time. Both API paths are host-to-host: you own the checkout and reconcile from webhooks. This is where the rest of the guide lives, because it’s the flow the docs in the SERP explain least.

The api vs plugin question comes down to one line: use the plugin when a plugin exists for your stack and the defaults fit; use the API when they don’t. A single WooCommerce store selling a fixed catalogue rarely needs the API at all, and building one where the plugin already does the job adds surface to maintain for no gain. The API earns its place when you own the checkout, price dynamically, run a marketplace that splits funds, or sit on a stack no plugin covers.

What you need before the first request

You need the host, two API keys, and a sandbox. The base host is https://api.speend.io, every call is a POST with a JSON body, and every request carries two headers: merchant (your Merchant UUID) and key (your API key). This is the setup a payment API for developers expects before any call goes out.

Two keys, not one, both sent in the same key header. The main API key authorizes payment calls; payouts and refunds take a separate withdrawal key from the dashboard, placed in that same key header. Both keys, your Merchant UUID, and an optional webhook password live in the merchant dashboard. Your store domain must be registered with Speend for the account to accept live traffic.

The sandbox mirrors production one to one, so you build and test against the same request and response shapes you’ll see live. Wrong credentials return {"error":"No access"}, a fast way to confirm your headers are wired correctly. Validation errors come back as HTTP 422 with {"status": false, "errors": [...]}, so branch on the status code rather than only the body.

Keep sandbox and production keys in separate configuration, never in code, and rotate them from the dashboard if either leaks. Set the webhook password even though it’s optional: it’s the second half of the signature check in the next section, and without it your verification rests on the API key alone. A store that skips it works, but it throws away a layer of defence for no saving.

Creating a payment

Call POST /payment/createPayment with order_id, amount, and currency; you get back a uuid, your order_id, the payment address, an address_qr_code, and the payer_amount the customer must send in payer_currency. order_id must be unique across your invoices, static wallets, and payouts; a duplicate is rejected with a 422 and the error order_id is not unique. Render the address and QR in your own checkout, then confirm the payment from webhooks.

Here’s a Bitcoin invoice — the same crypto payment API shape works for any supported asset, and pricing in fiat is a matter of sending currency as a fiat code plus to_currency for settlement.

curl -X POST https://api.speend.io/payment/createPayment \
  -H "merchant: <MERCHANT_UUID>" \
  -H "key: <API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{"order_id": "order-1042", "amount": "0.005", "currency": "BTC", "lifetime": 3600, "url_callback": "https://shop.example/webhooks/speend"}'
$body = json_encode([
    'order_id'     => 'order-1042',
    'amount'       => '0.005',
    'currency'     => 'BTC',
    'lifetime'     => 3600,
    'url_callback' => 'https://shop.example/webhooks/speend',
], JSON_UNESCAPED_UNICODE);

$ch = curl_init('https://api.speend.io/payment/createPayment');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => ['merchant: '.$merchantUuid, 'key: '.$apiKey, 'Content-Type: application/json'],
    CURLOPT_POSTFIELDS     => $body,
    CURLOPT_RETURNTRANSFER => true,
]);
$payment = json_decode(curl_exec($ch), true);
// render $payment['address'] and $payment['address_qr_code'] in your checkout
import requests

resp = requests.post(
    "https://api.speend.io/payment/createPayment",
    headers={"merchant": MERCHANT_UUID, "key": API_KEY},
    json={
        "order_id": "order-1042",
        "amount": "0.005",
        "currency": "BTC",
        "lifetime": 3600,
        "url_callback": "https://shop.example/webhooks/speend",
    },
)
payment = resp.json()
# show payment["address"] and payment["address_qr_code"]
const resp = await fetch("https://api.speend.io/payment/createPayment", {
  method: "POST",
  headers: {
    merchant: MERCHANT_UUID,
    key: API_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    order_id: "order-1042",
    amount: "0.005",
    currency: "BTC",
    lifetime: 3600,
    url_callback: "https://shop.example/webhooks/speend",
  }),
});
const payment = await resp.json();
// show payment.address and payment.address_qr_code

The response carries the address to show and the amount the payer must send:

{
  "uuid": "019ae4ef-…",
  "order_id": "order-1042",
  "amount": 0.005,
  "payer_amount": 0.005,
  "payer_currency": "BTC",
  "currency": "BTC",
  "address": "bc1q…",
  "address_qr_code": "iVBORw0KGgo…"
}

order_id is your identifier and your uniqueness key: a string of letters, digits, underscores, and hyphens, no spaces, unique per order and stable across retries. lifetime sets how long the invoice stays open, in seconds, so 3600 is one hour. url_callback is where webhooks land. To restrict or pre-select assets, pass currencies or except_currencies, each a list of {currency, network} pairs, or a single network. For a bitcoin payment API in particular, leaving network unset lets the payer pick the rail.

A few optional fields shape settlement and reconciliation. to_currency converts the invoice into a settlement asset and must be a crypto code, so you can price in a fiat currency and still settle in, say, USDT. subtract is the percent of the processing fee charged to the customer, from 0 to 100: at 100 the customer covers the whole fee on top of the amount. additional_data rides along with the invoice and comes back on status and webhook reads, which ties a payment to an order in your own system without a lookup table. accuracy_payment_percent sets the underpayment tolerance in percent, up to 5: at 5, an invoice counts as paid once the customer sends at least 95%.

How to know a payment went through

Read status two ways: POST /payment/info on demand, and the webhook pushed to your url_callback. The webhook is the source you act on; a payment is done only at status: 2 with is_final. Never mark an order fulfilled off invoice creation alone.

curl -X POST https://api.speend.io/payment/info \
  -H "merchant: <MERCHANT_UUID>" \
  -H "key: <API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{"order_id": "order-1042"}'

The webhook is the authority on status, and its status is numeric: 1 created, 2 paid, 3 canceled. It travels with is_final, which tells you the invoice is settled and can’t change again, so fulfil only on status: 2 with is_final: true.

FieldMeaningWhat you do
status: 1Created, no deposit yetLeave the order pending
status: 2PaidFulfil once is_final is true
status: 3Canceled or expired unpaidRelease the order and restock
is_finalInvoice settled, no further changeStop polling

Alongside status, a webhook carries the money you reconcile against: amount and amount_usd for the invoice, merchant_amount for what lands on your balance after commission, plus the payer’s from address, the network and currency, and the on-chain transaction hash as txid. Its type says whether the payment came from a gateway invoice (2) or a static wallet (1). A deposit isn’t final the moment it’s seen, so wait for is_final before you move the order; confirmation depth still varies by asset.

Webhooks without holes

Verify every webhook before you act on it, and never trust the body past the identifiers. The signature is an MD5 over a Base64-encoded copy of the payload with the sign field removed, concatenated with your API key and webhook password. The catch that breaks most integrations is key order: the signature is computed over the JSON exactly as sent, so you must parse the raw body preserving order and re-encode with the same compact, unescaped formatting.

$raw     = file_get_contents('php://input');
$payload = json_decode($raw, true);            // preserves key order
$received = $payload['sign'] ?? '';
unset($payload['sign']);

$expected = md5(
    base64_encode(json_encode($payload, JSON_UNESCAPED_UNICODE))
    . $apiKey . $webhookPassword                // '' if no webhook password
);

if (!hash_equals($expected, $received)) {
    http_response_code(400);
    exit;
}
import base64, hashlib, hmac, json

raw     = request.get_data()                    # exact bytes received
payload = json.loads(raw)
received = payload.pop("sign", "")

encoded  = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
expected = hashlib.md5(
    (base64.b64encode(encoded.encode()).decode() + api_key + webhook_password).encode()
).hexdigest()

if not hmac.compare_digest(expected, received):
    abort(400)
import crypto from "node:crypto";

const payload = JSON.parse(req.rawBody);        // parse the exact bytes received
const received = payload.sign;
delete payload.sign;

const encoded = JSON.stringify(payload);        // preserves key order
const expected = crypto
  .createHash("md5")
  .update(Buffer.from(encoded).toString("base64") + apiKey + webhookPassword)
  .digest("hex");

const ok =
  received.length === expected.length &&
  crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
if (!ok) return res.status(400).end();

Three rules turn a working handler into a correct one. Compare in constant time, so a byte-by-byte timing leak can’t help an attacker guess the signature. Re-query the status through /payment/info after verifying — take only uuid and order_id from the webhook and read the authoritative status from the API, so a replayed or malformed body can’t move an order. And deduplicate by the last status you applied: Speend retries a webhook, and a stale status: 3 arriving after a 2 should be ignored.

Want to accept crypto payments on your website?

Fast setup and KYC/KYB, fee starts from 0.5%

Contact Us

Partial payments, overpayment, and expiry

Handle the money rather than a status vocabulary, because the API doesn’t emit separate codes for short or excess payments. Three cases still need explicit branches.

A short payment is governed by accuracy_payment_percent. Inside the tolerance you set, up to 5%, the invoice still flips to status: 2 and the amount actually received credits to merchant_amount. Outside it, the invoice stays open until the payer tops up or lifetime expires, so reconcile from merchant_amount, never the requested amount.

An overpayment also settles as status: 2. The surplus shows as a merchant_amount above the invoice amount, so compare the two and credit or refund the difference by your own policy.

An expiry or cancellation arrives as status: 3 once lifetime runs out unpaid. Release any reserved stock and close the order. Because webhooks retry, a stale 3 can land after a 2, and your dedup rule drops it.

Unique order IDs and safe retries

order_id must be unique across your invoices, static wallets, and payouts. A second call with an order_id you’ve already used doesn’t return the original object; it’s rejected with a 422 and order_id is not unique. That uniqueness is what stops a double-charge, but it also means a naive retry after a timeout can fail even though nothing is wrong.

CallKeySecond call with the same value
/payment/createPaymentorder_id422, order_id is not unique
/payout/createorder_id422, order_id is not unique

So handle the timeout case deliberately. When a create call times out, you don’t know whether it landed. Look the order_id up first — /payment/info for a payment, /payout/getStatus for a payout — and if the object exists, the original succeeded. Only if the lookup finds nothing do you retry, and you retry with the same order_id. Generate that id once per business event and store it before the call, never from a timestamp or a random value at call time.

Mass payouts

Send bulk payouts by calling POST /payout/create per recipient with the withdrawal key; unique order_ids keep the batch safe to resume. There’s one payout endpoint, so a mass run is a loop, and because a duplicate order_id is rejected with a 422, a crash mid-batch can’t double-pay. On resume, check /payout/getStatus per order_id and send only the recipients with no record yet. This is the api mass payments pattern the fiat-only pages in the SERP don’t cover for crypto.

curl -X POST https://api.speend.io/payout/create \
  -H "merchant: <MERCHANT_UUID>" \
  -H "key: <WITHDRAWAL_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{"order_id": "payout-7781", "currency": "USDT", "network": "tron", "address": "T...", "amount": "250.00", "url_callback": "https://shop.example/webhooks/payout"}'
$body = json_encode([
    'order_id'     => 'payout-7781',
    'currency'     => 'USDT',
    'network'      => 'tron',
    'address'      => 'T...',
    'amount'       => '250.00',
    'url_callback' => 'https://shop.example/webhooks/payout',
], JSON_UNESCAPED_UNICODE);

$ch = curl_init('https://api.speend.io/payout/create');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => ['merchant: '.$merchantUuid, 'key: '.$withdrawalKey, 'Content-Type: application/json'],
    CURLOPT_POSTFIELDS     => $body,
    CURLOPT_RETURNTRANSFER => true,
]);
$payout = json_decode(curl_exec($ch), true);
import requests

for r in recipients:
    requests.post(
        "https://api.speend.io/payout/create",
        headers={"merchant": MERCHANT_UUID, "key": WITHDRAWAL_KEY},
        json={
            "order_id": r["order_id"],   # unique and stable per recipient
            "currency": "USDT",
            "network": "tron",
            "address": r["address"],
            "amount": r["amount"],
            "url_callback": "https://shop.example/webhooks/payout",
        },
    )
for (const r of recipients) {
  await fetch("https://api.speend.io/payout/create", {
    method: "POST",
    headers: {
      merchant: MERCHANT_UUID,
      key: WITHDRAWAL_KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      order_id: r.order_id,          // unique and stable per recipient
      currency: "USDT",
      network: "tron",
      address: r.address,
      amount: r.amount,
      url_callback: "https://shop.example/webhooks/payout",
    }),
  });
}

/payout/create returns uuid, order_id, address, amount, currency, network, a status of REQUESTED, and created_at. Poll /payout/getStatus by order_id, or read the callback: once the send lands, status becomes SUCCESS and a transaction_data object carries the on-chain txid, the from and to addresses, and the amount. A 422 with insufficient balance means the payout was never created.

Each payout carries the network’s own fee, at the blockchain’s cost and without a markup on top, so the rail you choose changes what a batch costs to send. Pick the network per recipient with that in mind rather than defaulting every payout to the same chain, and measure the current cost at send time — on-chain fees move, and a figure written into your logic last quarter won’t match today’s.

Reusable addresses with static wallets

Use POST /payment/createStaticWallet when you want one permanent address a customer can pay more than once, instead of a fresh invoice each time. It suits donations, per-customer deposit addresses, and account top-ups. Pass wallet_id, currency, network, and url_callback; you get back a uuid, the address, and a qr_base64_png to render. Every deposit to that address fires a webhook, so you reconcile the same way as invoices.

curl -X POST https://api.speend.io/payment/createStaticWallet \
  -H "merchant: <MERCHANT_UUID>" \
  -H "key: <API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{"wallet_id": 1, "currency": "TRX", "network": "TRON", "url_callback": "https://shop.example/webhooks/speend"}'

Your wallet_id follows the same rule as order_id: unique per merchant, so a repeat returns a 422. Because a static address stays open indefinitely, treat each webhook as a distinct deposit rather than a one-time invoice settling.

Testing before you go live

Run the whole flow in the sandbox first, because the failure modes are what break in production, not the happy path. The sandbox mirrors production one to one, so the request and response shapes you exercise are the ones you’ll ship.

Cover five paths before you switch keys. Confirm signature verification passes on a valid webhook and rejects a tampered one. Walk the states: a clean status: 2, a short payment inside and outside accuracy_payment_percent, and an expiry status: 3, and confirm is_final gates fulfilment. Repeat createPayment and /payout/create with the same order_id and confirm the 422. Fire the same webhook twice and confirm your dedup drops the replay. Finally, force a webhook retry and confirm your handler stays consistent across it.

FAQ

How is the API different from the plugin, and when do you need the API?
The plugin is a ready-made integration for WooCommerce that handles checkout, status sync, and webhooks for you. The API is the same processor without the CMS layer: you call the endpoints yourself. Use the API when you run your own checkout or backend, or when your platform has no plugin.

How do you verify a webhook is genuine?
Recompute the signature: MD5 of Base64-encoded JSON of the payload with sign removed, concatenated with your API key and webhook password. Parse the raw body preserving key order, compare in constant time, and reject on mismatch. Then re-query /payment/info and act on that status, not the body.

What happens on underpayment or overpayment?
Neither has its own status. A short payment inside accuracy_payment_percent still settles as status: 2; outside it, the invoice stays open until topped up or expired. An overpayment settles as status: 2 too, with the surplus visible as merchant_amount above the invoice amount, so reconcile from merchant_amount.

Can you retry a create-payment call with the same order_id?
Not blindly. order_id must be unique, so a duplicate returns a 422 with order_id is not unique rather than the original invoice. After a timeout, look the order up with /payment/info first: if it exists, the first call succeeded; if not, retry with the same order_id.

Are there ready-made SDKs?
There’s no separate public SDK to install. The PHP client ships inside the WooCommerce plugin, which you can download and read as a reference implementation of every call in this guide.

How do mass payouts work?
Loop /payout/create per recipient with the withdrawal key. Because order_id must be unique, a re-run can’t double-pay: an already-sent payout returns a 422, so on resume check /payout/getStatus per order_id and send only the ones with no record yet.

Where to go next

Start in the sandbox and wire the four calls that carry the integration: create a payment, read status, verify the webhook, send a payout. Confirm each status path before you point anything at production, and keep the WooCommerce plugin open as your reference — it’s the same processor, and every call above is in its source.

Share
Michael Brown
Author

Fintech and crypto industry specialist with expertise in blockchain-based payments, cryptocurrency infrastructure, risk management, and financial technology. He writes about the development of digital finance, the adoption of crypto payments, emerging market trends, and the technologies transforming international transactions. Michael combines industry analysis with a practical perspective on how businesses can use modern financial tools securely and efficiently.