Skip to content
System nominalUTC --:--:--Articles 080
MODULE 05 Lesson 13 / 18 35 MIN

Payments and shipping

Payment Gateway Integration

Extending WC_Payment_Gateway is the easy half. The hard half is what your gateway returns when the payment provider does not answer clearly.

A store owner installs a payment extension, places a test order, and watches the confirmation page appear. The order is marked paid. The customer's card was never charged, because the request to the provider timed out and the extension treated the missing answer as an answer. Two weeks later the same bug runs the other way: a timeout happens after the charge succeeded, the customer pays again, and the store has two payments for one order.

Writing the gateway class is a morning's work. The part that costs money is the branch where you do not know what happened: the request left, the socket closed, and nothing came back. Everything in this lesson builds toward that branch. The API is small enough to state completely, and the decisions it forces are not.

WC_Payment_Gateway is a settings screen with a charge method attached

The class extends WC_Settings_API, which is why a gateway can render its own admin form without you writing any HTML. The parent constructor chain gives you init_form_fields() for the field definitions, init_settings() to load them, get_option() to read one, and process_admin_options() to save them. The option key is plugin_id . id . '_settings', so a gateway with id of sifero_stub stores its settings under woocommerce_sifero_stub_settings.

The methods that decide behaviour are five, and they are called from different places, which is why their contracts get mixed up.

MethodCalled fromWhat it decides
is_available() WC_Payment_Gateways::get_available_payment_gateways() Whether the gateway appears in the list the customer can choose from.
payment_fields() The checkout template, per gateway The card form or description rendered under the radio button.
validate_fields() WC_Checkout::validate_checkout() Whether the posted fields are acceptable before an order is created.
process_payment() WC_Checkout::process_order_payment() Whether the order becomes paid, and where the customer goes next.
process_refund() Admin refund action, when the gateway supports refunds Nothing about the original payment.

is_available() in the base class reads $this->enabled and, if the cart exists and holds a total, compares that total with $this->max_amount. Zero in max_amount means no ceiling. An extension that refuses small orders, or orders above a risk threshold, overrides this method and calls the parent last rather than reimplementing the comparison.

<?php
add_filter(
	'woocommerce_payment_gateways',
	function ( $gateways ) {
		$gateways[] = 'Sifero_Gateway_Stub';
		return $gateways;
	}
);

Registering the class name as a string matters when the file is loaded early. A plugin's main file is included before WC_Payment_Gateway exists, so a class that extends it at file scope fatals on activation. Load the class on woocommerce_loaded, which WooCommerce fires from WC_WooCommerce::on_plugins_loaded() after its own includes are in place.

Settings live in form fields, and the mode switch belongs in them

init_form_fields() is the only place a gateway describes its own configuration. Field types are the standard WC_Settings_API set: text, textarea, checkbox, select, number, password, title. Each field gets a default, and set_defaults() fills in the gaps so get_option() never returns undefined.

<?php
public function init_form_fields() {
	$this->form_fields = array(
		'enabled'    => array(
			'title'   => 'Enable/Disable',
			'type'    => 'checkbox',
			'label'   => 'Enable Sifero Stub',
			'default' => 'no',
		),
		'title'      => array(
			'title'   => 'Title',
			'type'    => 'text',
			'default' => 'Card (stub)',
		),
		'test_mode'  => array(
			'title'   => 'Test mode',
			'type'    => 'checkbox',
			'label'   => 'Use the sandbox endpoint',
			'default' => 'yes',
		),
		'max_amount' => array(
			'title'       => 'Maximum order total',
			'type'        => 'number',
			'default'     => '0',
			'description' => 'Zero means no maximum.',
		),
	);
}

Test mode has one dangerous shape: it is a checkbox on the settings row, which means the live credentials, the live webhook secret and the live provider account are one click away from being used with test traffic, or the other way round. Derive both the endpoint and the signing secret from that single field rather than keeping two sets of credentials in two options that can drift.

<?php
public function endpoint_base() {
	return 'yes' === $this->get_option( 'test_mode' )
		? 'https://sandbox.gateway.example/v1'
		: 'https://api.gateway.example/v1';
}

public function webhook_secret() {
	return 'yes' === $this->get_option( 'test_mode' )
		? (string) get_option( 'sifero_gateway_test_webhook_secret', '' )
		: (string) get_option( 'sifero_gateway_live_webhook_secret', '' );
}

On my test store, toggling the field and printing endpoint_base() moved the URL from https://sandbox.gateway.example/v1 to https://api.gateway.example/v1 on the next request. That is worth doing once, because it is the difference between a sandbox charge and a real one.

A process_payment that returns nothing leaves a pending order and no message

Here is the whole contract. process_payment( $order_id ) must return an array with result set to success and a redirect URL. That is the only shape WC_Checkout::process_order_payment() acts on. The method stores the order id in the session as order_awaiting_payment, saves the session early, calls your gateway, and then:

$result = $available_gateways[ $payment_method ]->process_payment( $order_id );

if ( isset( $result['result'] ) && 'success' === $result['result'] ) {
	$result['order_id'] = $order_id;
	$result = apply_filters( 'woocommerce_payment_successful_result', $result, $order_id );
	// redirect to $result['redirect'], or JSON for the AJAX checkout
}
// anything else: no redirect, no error, no log line

Read that again, because it explains the most common gateway bug in the wild. When process_payment returns an empty array, returns null, throws nothing, or returns result => 'failure' without adding a notice, the classic checkout does nothing at all. The customer stays on the checkout page with a form that appears to have worked, an order sits at pending, and the store owner finds it later. The fix is not a return value, it is a visible notice, and the convention is WooCommerce's own:

<?php
wc_add_notice( 'We could not confirm your payment. Nothing has been charged. Try again.', 'error' );

return array(
	'result'  => 'failure',
	'message' => 'Gateway timeout',
);

The Blocks checkout path is stricter and reaches the same method through a different door. The Store API fires woocommerce_rest_checkout_process_payment_with_context, and StoreApi\Legacy hooks that action at priority 999 for gateways that do not register their own handler. It calls validate_fields(), converts any notices into an exception, calls process_payment(), and accepts success, failure, pending or error as statuses. A missing result is treated as failure. So the same ambiguity produces an error banner in the Checkout block and complete silence on the shortcode checkout.

The order must not be paid when the answer is ambiguous

Classify the provider's response before you touch the order. Three cases exist, and they need three different outcomes.

What came backWhat you knowWhat to do
2xx with a charge id and an amount that matches the order The charge exists and is identifiable payment_complete( $charge_id ), then redirect.
4xx with a decline code No charge was created Notice, return failure, leave the order pending.
Timeout, connection reset, 5xx, unreadable body You do not know whether a charge exists Leave the order pending. Record a note. Never complete it.

The third row is the one extensions get wrong, usually by wrapping the request in a try/catch that completes the order when the exception message looks unimportant, or by treating any HTTP response as a charge. A timeout is not a failure. It is an absence of information, and the only safe direction to fail in is unpaid: an unpaid order that was really charged is a support ticket, and a paid order that was never charged is inventory leaving the building.

<?php
public function process_payment( $order_id ) {
	$order = wc_get_order( $order_id );

	$response = wp_remote_post(
		$this->endpoint_base() . '/charges',
		array(
			'timeout' => 30,
			'headers' => array(
				'Idempotency-Key' => $this->idempotency_key( $order ),
				'Content-Type'    => 'application/json',
			),
			'body'    => wp_json_encode( array( 'amount' => $order->get_total() ) ),
		)
	);

	if ( is_wp_error( $response ) || 500 <= wp_remote_retrieve_response_code( $response ) ) {
		$order->add_order_note( 'Gateway did not confirm or deny the charge. Order left pending.' );
		wc_add_notice( 'We could not confirm your payment. Nothing has been charged. Try again.', 'error' );

		return array( 'result' => 'failure' );
	}

	$body = json_decode( wp_remote_retrieve_body( $response ), true );

	if ( empty( $body['id'] ) || empty( $body['amount'] ) ) {
		$order->add_order_note( 'Gateway answered without a charge id. Order left pending.' );
		wc_add_notice( 'We could not confirm your payment. Please contact us.', 'error' );

		return array( 'result' => 'failure' );
	}

	$order->payment_complete( $body['id'] );
	WC()->cart->empty_cart();

	return array(
		'result'   => 'success',
		'redirect' => $this->get_return_url( $order ),
	);
}

Two things in there carry more weight than they look. The charge id and the amount are both required, because a response without them cannot be tied to this order. And the Idempotency-Key is derived from the order, not generated per request, so a retry after a timeout asks the provider about the same charge instead of creating a second one. Store the charge id on the order as soon as you have it, and check for it at the top of the method: a second call for an order that already has a charge should reuse that charge, not issue another.

I ran both paths on a WooCommerce 11.2.1 / WordPress 7.0.7 store with HPOS enabled. The settled case produced status=processing, date_paid set, an empty cart, and a redirect to /checkout/order-received/14/?key=wc_order_.... The ambiguous case produced status=pending, date_paid unset, the cart still holding its item, no redirect, and the reason recorded as an order note. Retrying the second order returned the same charge id, which is the property that stops the double charge.

Empty the cart after payment_complete, not before

In the settled branch above, WC()->cart->empty_cart() runs after payment_complete(). Reversed, a gateway that fails between the two lines leaves the customer with an empty cart and a pending order, which is the worst of both states. On the ambiguous branch the cart is never emptied at all, because the customer is about to try again.

Redirect, webhook, or both, decided by who knows the truth

Some providers settle the charge inside the request. Others hand you a redirect URL and settle later, and the customer's browser is then the worst possible messenger: it can close the tab, lose the network, or replay the return URL. The rule that keeps this straight is to ask which party knows the payment is final.

  • Settled in the request. process_payment() has a charge id, so it completes the order and redirects. No webhook is needed for the happy path, though the provider will usually send one anyway.
  • Redirect or hosted page. process_payment() returns success with the provider's URL, and the order stays pending or moves to on-hold. The confirmation arrives by webhook, which is the subject of the next lesson. The return URL is a convenience for the customer, not evidence.
  • Both. Complete the order from whichever arrives first and make the second one harmless. That is an idempotency problem, and it is the reason the next lesson exists.
Lab 40 minutes

Build a gateway that can be made to time out, and prove what the store does in each case.

  1. Write a plugin with a gateway class that renders a text field in payment_fields(), requires it in validate_fields(), and has a test_mode checkbox plus a maximum-amount field.
  2. Register it with woocommerce_payment_gateways and load the class on woocommerce_loaded.
  3. Give process_payment() three branches: a settled charge, a decline, and a simulated timeout that returns failure with an error notice and never completes the order.
  4. Place an order on the settled branch. Print the order status, the transaction id, whether date_paid is set, the cart item count and the redirect URL.
  5. Place an order on the timeout branch. Print the same five things, plus the order notes.
  6. Set max_amount to a value below the cart total and print get_available_payment_gateways() before and after.

Then answer in one sentence each: why a timeout must not call payment_complete(), and why empty_cart() belongs after it.

Show the solution and the reasoning

The important decision in this lab is where the failure surfaces. A gateway that returns failure without a notice is invisible on the shortcode checkout, so the notice is part of the answer, not decoration. My version keeps the notice and the order note in the same branch, which means the customer and the store owner read the same sentence.

<?php
public function validate_fields() {
	if ( empty( $_POST['sifero_holder'] ) ) {
		wc_add_notice( 'The name on the card is missing.', 'error' );
		return false;
	}
	return true;
}

public function process_payment( $order_id ) {
	$order = wc_get_order( $order_id );

	if ( 'yes' === $order->get_meta( '_sifero_simulate_timeout' ) ) {
		$order->add_order_note( 'Gateway request timed out after 30s. No charge confirmation received.' );
		wc_add_notice( 'We could not confirm your payment. Nothing has been charged.', 'error' );

		return array( 'result' => 'failure', 'message' => 'Gateway timeout' );
	}

	$existing = $order->get_meta( '_sifero_charge_id' );

	if ( $existing ) {
		$charge_id = $existing;                    // retry of an order already charged
	} else {
		$charge_id = 'ch_' . substr( md5( $order->get_order_key() ), 0, 16 );
		$order->update_meta_data( '_sifero_charge_id', $charge_id );
	}

	$order->set_transaction_id( $charge_id );
	$order->save();
	$order->payment_complete( $charge_id );
	WC()->cart->empty_cart();

	return array( 'result' => 'success', 'redirect' => $this->get_return_url( $order ) );
}

On my run the settled order printed status=processing txn=ch_dffea2959b9f51ff, date_paid set: yes, cart items after: 0, and the order-received URL. The timeout order printed status=pending txn=, date_paid set: no, cart items after: 1, no redirect, and the note about the missing confirmation. Retrying it produced the same charge id both times, which is the check I would add to any gateway before shipping: charge, retry, compare.

The amount ceiling answer is the one people skip. With max_amount set to 10.00 on a 25.00 cart, get_available_payment_gateways() drops the gateway from the list, so the radio button disappears rather than failing at submit time. That is the base class behaviour, and it only works if you let is_available() run its comparison instead of overriding it wholesale.

Two limits worth stating. Leaving an order pending on a timeout means the customer may retry and the provider may yet settle the first attempt, so the gateway needs an idempotency key derived from the order and a reconciliation job that asks the provider about unresolved orders. And a gateway that only ever completes orders from webhooks will show customers a pending order on the thank-you page, which is correct but needs a sentence of explanation in the email.

Official reference

The WordPress developer reference for what this lesson uses.

Progress

Mark this lesson done and it stays marked in this browser.