David Lewin David Lewin
E-Commerce, Research & Ads

Accepting crypto in WooCommerce: verifying CoinGate callbacks and handling over- and underpayments

Disclosure: some links in this guide are affiliate links. If you sign up through them, I may earn a commission at no extra cost to you. How this site makes money.

Short answer

Treat a payment gateway's callback as a hint, not as proof of payment. CoinGate's callbacks carry no signature header, so verify them in two steps: give each order its own secret token (an HMAC of the order ID, so nothing needs storing) and reject any callback without it, then read the order back from CoinGate's API and act only on what the API says. Ship only when the API reports the status paid; confirming means the transaction is still waiting for network confirmations. For wrong amounts, let CoinGate's underpaid cover absorb small shortfalls (1-2% covers most wallet fees), put bigger shortfalls on hold for a person instead of cancelling them, and note overpayments so support can point the customer to CoinGate's refund form. Settle in EUR or USDC if you don't want to hold price risk. Our tested WooCommerce gateway does all of this; CoinGate charges 1% per payment with no monthly fee, and NOWPayments is a good alternative with HMAC-signed callbacks.

Taking crypto looks simple from the shopper's side: pick BTC or USDC, scan a QR code, done. The parts that cause trouble are on yours. Your store learns about a payment from an HTTP request that anyone on the internet could send, the amount that arrives is often not the amount you asked for, and the payment goes through stages over several minutes, not one approval. This guide builds a small WooCommerce gateway on CoinGate's API that handles all three, and explains each decision so you can apply it to any crypto processor.

How this guide was made

We tested the gateway in a real WordPress 7.1.3 and WooCommerce 11.2.1 store on PHP 8.4 and MariaDB. Orders were placed through WooCommerce's Store API checkout. A mock CoinGate API answered the gateway's requests and played each payment outcome: forged callbacks, a callback that lies about the status, confirmations, an outage, overpayment, covered and uncovered underpayment, expiry and a changed amount. We didn't run payments through CoinGate's real API or sandbox. Request fields, statuses, the retry schedule and fees come from CoinGate's API documentation, help center and pricing page, checked in October 2026.

1. The short answer

QuestionCoinGateNOWPayments
How you know a callback is genuineA token you choose per order is sent back with every callback; then read the order from the APIHMAC-SHA512 signature in the x-nowpayments-sig header, computed with your IPN secret
When to shipStatus paidStatus finished
Small underpayments"Underpaid cover": you choose 0-10%, CoinGate accepts the order and you absorb the differenceStatus partially_paid; compare actually_paid with the price yourself
Over- and larger underpaymentsThe customer can request a refund from the CoinGate invoice pageHandled through NOWPayments' dashboard and support
Fees1% per payment, no monthly feeSee NOWPayments' pricing page

2. The payment path

A crypto order from checkout to WooCommerce
A crypto order from checkout to WooCommerceWooCommerce checkout → CoinGate invoice → Network confirmations → Callback to your store → Paid / Short and expired / Nothing paidWooCommerce checkout: order created as pending; the gateway creates a CoinGate order with a per-order tokenWooCommerce checkoutorder created as pending; the gateway creates a CoinGate orderwith a per-order tokenCoinGate invoice: the shopper picks BTC, USDC or another coin; the rate holds for 20 minutesCoinGate invoicethe shopper picks BTC, USDC or another coin; the rate holds for20 minutesNetwork confirmations: status confirming: the transaction is seen, not yet finalNetwork confirmationsstatus confirming: the transaction is seen, not yet finalCallback to your store: token checked, then the order is read back from CoinGate's APICallback to your storetoken checked, then the order is read back from CoinGate's APIPaid: processing: ship itPaidprocessing: shipitShort and expired: on-hold for a personShort andexpiredon-hold for apersonNothing paid: cancelledNothing paidcancelled
The callback only tells your store to look. The decision comes from the order as CoinGate's API reports it, read with your API token over HTTPS.

3. Why you can't trust the callback itself

Your callback URL is public: it is in the API request and in your WooCommerce code, and it has to accept POST requests from the internet. If your store marks an order paid because a request said "status": "paid", anyone who places an order and then sends that request gets the goods for free.

Processors handle this in different ways. NOWPayments and Stripe sign each webhook with a shared secret. CoinGate's callbacks have no signature header. Instead, you pass a token when you create the order, and CoinGate sends it back in every callback for that order. CoinGate's own advice adds a second step: take the order ID from the callback and confirm the status and amount with a separate API call. The gateway below does both:

  • The token is an HMAC of the WooCommerce order ID with a secret from wp-config.php. Nothing is stored, every order gets a different token, and nobody without the secret can make one. A callback with a wrong or missing token gets a 403.
  • The status and amounts come from the API, never from the request body. The gateway reads the order by the CoinGate ID it saved at checkout, not by an ID taken from the callback.
  • The API answer must match the WooCommerce order: same order ID, currency and total. If someone changed the order after the invoice was created, the order goes on hold instead of being marked paid.

CoinGate also publishes the IP addresses its callbacks come from (api.coingate.com/v2/ips-v4). An allowlist adds little once the API re-read is in place, and a stale one silently blocks real callbacks, so we don't use one.

4. The gateway

Save this as wp-content/mu-plugins/coingate-gateway.php (or put it in a plugin) and add three constants to wp-config.php: your API token, a long random secret, and while testing, COINGATE_API set to https://api-sandbox.coingate.com/api/v2.

<?php
/**
 * Plugin Name: CoinGate crypto gateway (minimal)
 * Description: Creates a CoinGate order at checkout, sends the shopper to CoinGate's invoice, and updates the WooCommerce
 * order from CoinGate callbacks. A callback is only a hint: the plugin checks its token, then reads the order back from
 * CoinGate's API and acts on that.
 *
 * wp-config.php: COINGATE_API_TOKEN, COINGATE_SECRET (long random string), optionally
 * COINGATE_API ('https://api-sandbox.coingate.com/api/v2' while testing; live is the default)
 */

add_action( 'plugins_loaded', function () {
	if ( ! class_exists( 'WC_Payment_Gateway' ) ) {
		return;
	}

	class CG_Gateway extends WC_Payment_Gateway {
		public function __construct() {
			$this->id                 = 'coingate_min';
			$this->method_title       = 'CoinGate (minimal)';
			$this->method_description = 'Pay with BTC, USDC and other crypto through CoinGate.';
			$this->title              = 'Crypto (BTC, USDC and more)';
			$this->enabled            = 'yes';
			$this->has_fields         = false;
		}

		public function process_payment( $order_id ) {
			$order = wc_get_order( $order_id );
			$res   = cg_api( 'POST', '/orders', [
				'order_id'         => (string) $order->get_id(),
				'price_amount'     => $order->get_total(),
				'price_currency'   => $order->get_currency(),
				'receive_currency' => 'DO_NOT_CONVERT', // or EUR, USDC, BTC: what you want to be credited in
				'title'            => get_bloginfo( 'name' ) . ' order ' . $order->get_order_number(),
				'description'      => 'Order ' . $order->get_order_number(),
				'callback_url'     => WC()->api_request_url( 'cg_callback' ),
				'success_url'      => $order->get_checkout_order_received_url(),
				'cancel_url'       => $order->get_cancel_order_url_raw(),
				'token'            => cg_token( $order->get_id() ),
				'shopper'          => [ 'email' => $order->get_billing_email() ],
			] );
			if ( empty( $res['id'] ) || empty( $res['payment_url'] ) ) {
				wc_add_notice( 'Crypto payment is unavailable right now. Please try again or choose another method.', 'error' );
				return [ 'result' => 'failure' ];
			}
			$order->update_meta_data( '_cg_order_id', (int) $res['id'] );
			$order->save();
			return [ 'result' => 'success', 'redirect' => $res['payment_url'] ];
		}
	}

	add_filter( 'woocommerce_payment_gateways', fn( $g ) => array_merge( $g, [ 'CG_Gateway' ] ) );
} );

function cg_api( $method, $path, $body = null ) {
	$r = wp_remote_request( ( defined( 'COINGATE_API' ) ? COINGATE_API : 'https://api.coingate.com/api/v2' ) . $path, [
		'method'  => $method,
		'timeout' => 15,
		'headers' => [ 'Authorization' => 'Token ' . COINGATE_API_TOKEN, 'Content-Type' => 'application/json' ],
		'body'    => $body ? wp_json_encode( $body ) : null,
	] );
	if ( is_wp_error( $r ) || wp_remote_retrieve_response_code( $r ) !== 200 ) {
		return null;
	}
	return json_decode( wp_remote_retrieve_body( $r ), true );
}

// Derived from the order ID, so nothing to store, and nobody without COINGATE_SECRET can produce it.
function cg_token( $order_id ) {
	return hash_hmac( 'sha256', 'coingate-order-' . $order_id, COINGATE_SECRET );
}

// ?wc-api=cg_callback. CoinGate retries until it gets 200 or 204 (up to 40 times over about 6.5 days), and stops at once
// on 401 or 403.
add_action( 'woocommerce_api_cg_callback', function () {
	$in    = json_decode( file_get_contents( 'php://input' ), true ) ?: wp_unslash( $_POST ); // JSON or form callbacks
	$order = wc_get_order( absint( $in['order_id'] ?? 0 ) );
	if ( ! $order || ! hash_equals( cg_token( $order->get_id() ), (string) ( $in['token'] ?? '' ) ) ) {
		status_header( 403 ); // forged or for another site: don't retry
		exit;
	}
	// Don't trust status or amounts in the request body. Read the order from CoinGate, by the ID we stored at checkout.
	$cg = cg_api( 'GET', '/orders/' . (int) $order->get_meta( '_cg_order_id' ) );
	if ( ! $cg ) {
		status_header( 503 ); // CoinGate unreachable: let it retry
		exit;
	}
	cg_apply( $order, $cg );
	status_header( 200 );
	exit;
} );

function cg_apply( WC_Order $order, array $cg ) {
	$status = $cg['status'];
	$pay    = fn( $f ) => ( $cg[ $f ] ?? '0' ) . ' ' . ( $cg['pay_currency'] ?? '' );
	if ( (string) $cg['order_id'] !== (string) $order->get_id() || strtoupper( $cg['price_currency'] ) !== $order->get_currency()
		|| abs( (float) $cg['price_amount'] - (float) $order->get_total() ) > 0.005 ) {
		$order->update_status( 'on-hold', 'CoinGate order does not match this order (ID, currency or amount). Check it by hand.' );
		return;
	}
	if ( $order->get_meta( '_cg_status' ) === $status ) {
		return; // CoinGate retried a callback we already handled
	}
	$order->update_meta_data( '_cg_status', $status );
	$order->save();

	if ( $order->is_paid() ) { // never move a paid order backwards; just record what happened
		$order->add_order_note( "CoinGate status is now $status." );
		return;
	}
	switch ( $status ) {
		case 'confirming':
			$order->update_status( 'on-hold', 'Crypto payment seen on the network, waiting for confirmations.' );
			break;
		case 'paid':
			// CoinGate marks an order paid when the full amount arrived, or when the shortfall is within the
			// "underpaid cover" you set in CoinGate (you absorb that difference).
			$order->payment_complete( (string) $cg['id'] );
			if ( (float) ( $cg['overpaid_amount'] ?? 0 ) > 0 ) {
				$order->add_order_note( 'Customer overpaid by ' . $pay( 'overpaid_amount' ) . '. They can claim it back on the CoinGate invoice page.' );
			}
			if ( (float) ( $cg['underpaid_amount'] ?? 0 ) > 0 ) {
				$order->add_order_note( 'Accepted with a shortfall of ' . $pay( 'underpaid_amount' ) . ' (covered under your CoinGate underpaid setting).' );
			}
			break;
		case 'expired':
		case 'invalid':
			if ( (float) ( $cg['underpaid_amount'] ?? 0 ) > 0 ) {
				// Money arrived but not enough, after the invoice expired. Don't cancel: a person decides whether to accept
				// it, ask for the rest or point the customer to the refund form on the CoinGate invoice page.
				$order->update_status( 'on-hold', 'Underpaid by ' . $pay( 'underpaid_amount' ) . " and the invoice is $status. Not shipped; contact the customer." );
			} else {
				$order->update_status( $status === 'invalid' ? 'failed' : 'cancelled', "CoinGate invoice $status." );
			}
			break;
		case 'canceled':
			$order->update_status( 'cancelled', 'Customer cancelled the crypto invoice.' );
			break;
		default: // new, pending
			$order->add_order_note( "CoinGate status: $status." );
	}
}

How it fits WooCommerce:

  • Checkout: process_payment() creates the CoinGate order and returns its payment_url as the redirect. The order stays pending payment until a callback says otherwise.
  • Callback endpoint: WC()->api_request_url('cg_callback') gives /wc-api/cg_callback/ (or ?wc-api=cg_callback without pretty permalinks). It accepts JSON and form-encoded callbacks, because CoinGate sends whichever format your API app is set to.
  • Response codes matter. CoinGate retries until it gets a 200 or 204: every minute at first, then at growing intervals, 40 times in all over about six and a half days. It stops at once on 301, 302, 401 and 403. The gateway answers 403 to forged callbacks so they aren't retried, and 503 when CoinGate's API can't be reached, so the real callback comes back later.
  • Retries are harmless. The last status handled is saved on the order, so the same callback twice adds nothing, and a paid order is never moved backwards.
  • Block checkout: the class works in the classic checkout and through the Store API, which is how we tested it. To show it in the checkout block, register it with WooCommerce's payment method integration for blocks (a small PHP class and a few lines of JavaScript), or use CoinGate's official WooCommerce plugin for the checkout and apply the verification rules here to anything custom.

5. Statuses: what each one means for the order

CoinGate statusMeaningWhat the gateway does
newInvoice created, no coin chosen yet; expires after 2 hoursOrder note only
pendingCoin chosen, waiting for the payment; expires after 20 minutesOrder note only
confirmingPayment sent, waiting for network confirmationsOn hold: don't ship yet
paidConfirmed and credited to youProcessing, with CoinGate's order ID as the transaction ID
invalidNot confirmed by the network, or rejected in AML checksFailed, or on hold if money arrived
expiredNot paid in time, or paid too littleCancelled, or on hold if money arrived
canceledThe shopper cancelled the invoiceCancelled
refunded, partially_refundedMoney returned to the shopperOrder note; handle the WooCommerce refund yourself

The rule that prevents most losses: ship only on paid. A transaction in confirming has been broadcast but isn't final; on Bitcoin, an unconfirmed transaction can still be replaced by one that pays someone else. On hold is WooCommerce's status for "payment expected but not confirmed", and it keeps the order out of WooCommerce's automatic cleanup of unpaid pending orders.

6. Overpayments and underpayments

The amount that arrives often differs from the amount on the invoice, and usually it isn't the shopper's fault:

  • Wallet and exchange fees. Many wallets and exchanges take the network fee out of the amount sent, so the merchant receives slightly less. CoinGate names this as the usual cause of underpayment.
  • Paying late. The rate holds for 20 minutes. A payment sent after that, or one that takes long to be seen, is judged against an expired invoice.
  • Typing the amount by hand, rounding it, or sending twice.

CoinGate reports the difference in underpaid_amount and overpaid_amount, in the coin the shopper paid with. A change to these values doesn't trigger a callback of its own, which is one more reason to read the order from the API. The gateway handles three cases:

  • A small shortfall, inside your underpaid cover. In each CoinGate merchant tool you can set "Max % of Order Amount to Cover", from 0 to 10%. Within it, CoinGate accepts the order as paid and you absorb the difference. CoinGate suggests 1-2%, which it says covers most accidental underpayments. The gateway completes the order and records the shortfall in a note.
  • A bigger shortfall. The invoice expires with money received. Cancelling the order would be wrong: the customer has paid most of it. The gateway puts the order on hold with the amount in a note, so a person can decide whether to accept it, ask for the rest or point the customer to the refund option on the CoinGate invoice page. CoinGate refunds underpaid, overpaid and expired payments from that page; amounts under 3 EUR can't be refunded.
  • An overpayment. The order is complete. The gateway adds a note with the extra amount, so support can answer the "I sent too much" email by sending the customer to the invoice page.

Treat these as a person's decision, not code. A store that auto-cancels underpaid orders turns a fee rounding into a support ticket and an angry customer; a store that auto-completes them gets exploited.

7. Volatility: what you get paid in

The receive_currency you set when creating the order decides who holds the price risk:

  • EUR (fiat): CoinGate converts at once and pays a fixed amount. No price risk for you.
  • A coin such as BTC or USDC: credited at the rate locked when the invoice was created, whatever the shopper paid with. USDC keeps you close to dollars; BTC leaves you holding BTC.
  • DO_NOT_CONVERT: you keep whatever the shopper paid with, and all the price movement that comes with it.

The 20-minute rate window covers the shopper's side. What happens after that depends on this setting, so match it to how your accountant wants to see revenue.

8. What our test showed

All 16 checks passed on the real WooCommerce store:

  • Checkout created a CoinGate order with WooCommerce's total (80.00 USD), order ID, callback URL and a 64-character token, and sent the shopper to the invoice URL.
  • A callback with a wrong token, and one for an order that doesn't exist, got 403 and changed nothing.
  • A callback whose body said paid while the API still said new left the order pending. This is the attack the API re-read stops.
  • confirming put the order on hold; paid moved it to processing with CoinGate's ID as the transaction ID. The same callback sent again as JSON and as a form added nothing.
  • With CoinGate's API down, the callback got 503 and the order was unchanged, so CoinGate's retry would finish the job.
  • A later refunded callback added a note and left the paid order alone.
  • Overpaid by 3.12 USDC, and paid with a covered 0.40 USDC shortfall: both completed, each with a note giving the amount.
  • Expired 12.50 USDC short: on hold with a note, not cancelled. Expired with nothing paid: cancelled.
  • When CoinGate's order showed a different amount from the WooCommerce total, the order went on hold and wasn't marked paid.

9. CoinGate costs

ItemStandard plan
Processing fee1% per payment
Monthly feeNone
Crypto payouts0.50 EUR + 0.5%
Payouts with conversion0.50 EUR + 1.5%
EnterpriseCustom, volume-based fees

Compare that with card processing at roughly 2.9% plus a fixed fee, and no chargebacks: a confirmed crypto payment can't be pulled back by the customer's bank. Refunds are your decision, made through CoinGate.

10. NOWPayments: the alternative

NOWPayments covers the same ground with a different verification model. Its IPN callbacks carry an x-nowpayments-sig header: an HMAC-SHA512 of the request body, with its keys sorted, using the IPN secret from your dashboard. That lets you reject forgeries without an extra API call, but the check only works if you serialize the body exactly as NOWPayments does. Sorting only top-level keys and sorting nested keys too give different strings, so test it against a real IPN from their sandbox before going live. Underpayments arrive as partially_paid; ship only on finished, and compare actually_paid with the price before you do. Even with a valid signature, re-reading the payment from the API before shipping costs one request and closes the door on a leaked secret.

11. Our opinion: trade-offs

  • The verification pattern matters more than the processor. Token or signature to reject noise, API re-read to decide, amount check against the order, idempotent handling. Use it with any gateway.
  • CoinGate if you want the simplest path to taking crypto: fiat or stablecoin settlement, an official WooCommerce plugin, underpaid cover built in, and a flat 1% with no monthly fee.
  • NOWPayments if you want signed callbacks and a very long coin list, and you're comfortable getting the signature check exactly right.
  • Self-hosted (BTCPay Server) if you want no processor at all and can run a server and a Bitcoin node. No fees, all the operations work.
  • Settle in EUR or USDC unless holding crypto is a deliberate choice.

Related: scoring fraudulent WooCommerce orders, stopping carding bots at checkout and verifying Stripe webhooks for an affiliate program.

12. Tools

Top picks

ToolRoleWhy it fits
CoinGateCrypto payment gateway with API, WooCommerce plugin and sandboxPer-order callback tokens, an order API to verify against, underpaid cover and refunds from the invoice page; 1% per payment, no monthly fee, settlement in EUR, USDC or crypto.

Alternatives: free tools and references

We don't earn anything from these links.

13. Pitfalls

  • A redirect on the callback URL. An http URL that redirects to https, or a missing trailing slash, returns 301 and CoinGate stops retrying. Use the exact final URL.
  • Security layers in front of the callback. A site password (401) or a WAF rule (403) also stops retries at once, and the order stays pending forever. Exempt the callback path, and test it from outside after every security change.
  • Shipping on confirming. Wait for paid.
  • Trusting amounts in the callback. They can be forged, and over- and underpaid amounts change without a new callback. Read the order from the API.
  • Refunding in WooCommerce only. A WooCommerce refund doesn't send crypto back. Refund through CoinGate, then record it in WooCommerce.
  • WooCommerce's hold stock timer. Unpaid pending orders are cancelled after the "Hold stock" minutes (60 by default). CoinGate's 20-minute window fits inside it, and confirming orders move to on hold, but if you raise CoinGate's timeouts, raise this too.
  • Testing against the live API. Use a CoinGate sandbox account and app, with its own API token, until every status in the table above has been seen.
Bottom line

A crypto callback is a hint to go and look. Give every CoinGate order its own HMAC token, reject callbacks without it, read the order back from the API, check it against the WooCommerce total, and ship only on paid. Let a 1-2% underpaid cover absorb wallet fees, put bigger shortfalls on hold for a person, and note overpayments for support. CoinGate makes this straightforward at 1% per payment with EUR or USDC settlement; NOWPayments is a solid alternative with signed callbacks.

David Lewin
David Lewin

Founder of StealthScale.io and e-commerce infrastructure engineer writing field-tested playbooks on proxies, cloud VPS, anti-detect browsers and SEO.