| name | wc-payment-gateway |
| description | Register a custom WooCommerce payment gateway — extend WC_Payment_Gateway, declare $id / $title / $supports, implement process_payment returning array(result, redirect), optionally process_refund when refunds is in supports, register via the woocommerce_payment_gateways filter. The single most-confused thing AI gets wrong is payment_complete vs update_status — payment_complete runs the canonical paid-order state machine (status, transaction id, session flag, woocommerce_payment_complete action), update_status only changes the status string. Includes the always-forgotten WC cart empty_cart call after success. Use when integrating a payment provider, reviewing gateway code, or debugging "payment succeeded but cart didn't clear" / "order stuck in pending". Triggers on WC_Payment_Gateway, woocommerce_payment_gateways, process_payment, process_refund, payment_complete, get_return_url, woocommerce_payment_complete_order_status. |
| author | Soczó Kristóf |
| contact | mailto:lonsdale201@hotmail.com |
| plugin | woocommerce |
| plugin-version-tested | 10.7 |
| php-min | 7.4 |
| last-updated | "2026-04-28T00:00:00.000Z" |
| source | https://github.com/Lonsdale201/wp-agent-skills/tree/8684fef5b4c33bc0cd783f9fff7770b1f7f59c57/woocommerce/wc-payment-gateway |
| source-license | MIT |
| license | MIT |
WooCommerce: register a custom payment gateway
For plugins that integrate a payment provider (Stripe, Braintree, a national bank, a private gateway, an offline method) into WooCommerce. The skill covers the full flow: registration, settings, checkout rendering, payment processing, refunds, and the order status state machine — all source-verified against the WC 10.7 abstract and built-in BACS / cheque / COD reference implementations.
Misconception this skill corrects
"I'll call update_status( 'completed' ) after the API charges the card."
payment_complete() and update_status() are not the same thing. AI consistently uses update_status because it sounds more direct, then debugs for hours when:
- The order shows the right status but the customer email never sent.
- Stock didn't decrease.
- The "order_awaiting_payment" session flag stays true and the user can re-pay.
- The transaction ID doesn't get stored as order meta.
- Reports / analytics don't pick the order up as paid.
$order->payment_complete( $transaction_id ) (includes/class-wc-order.php) is the canonical "payment succeeded" call. It runs the full lifecycle: clears the session flag, sets the next status via the woocommerce_payment_complete_order_status filter (processing if the order needs processing, completed for digital-only), records the transaction_id, adds an order note, fires woocommerce_payment_complete action, saves. update_status is for everything else (on-hold while awaiting confirmation, failed on capture decline, etc.).
When to use this skill
Trigger when ANY of the following is true:
- Integrating a new payment provider with WooCommerce.
- Reviewing PR code that touches
WC_Payment_Gateway, process_payment, process_refund, or any of the woocommerce_payment_* hooks.
- Debugging "the customer was charged but the order is still pending" / "cart didn't empty after payment" / "refund button doesn't appear in admin".
- Migrating a v1-style gateway plugin (pre-WC 2.6) to modern conventions.
- Adding refund support to an existing gateway.
Architecture in one paragraph
A gateway is a PHP class extending WC_Payment_Gateway (includes/abstracts/abstract-wc-payment-gateway.php:31, itself extending WC_Settings_API), registered via the woocommerce_payment_gateways filter (includes/class-wc-payment-gateways.php:92). The class declares an $id, settings via init_form_fields(), customer-facing checkout via payment_fields(), and processes payment via process_payment( $order_id ) returning an array of result ('success' or 'failure') plus redirect (URL the customer goes to next). Refund support is opt-in via 'refunds' in the $supports array plus a process_refund( $order_id, $amount, $reason ) implementation. The order's lifecycle hooks (woocommerce_payment_complete, woocommerce_order_status_<status>) handle downstream side effects.
Minimal scaffold
Registration
add_filter( 'woocommerce_payment_gateways', static function ( array $gateways ): array {
$gateways[] = MyPlugin\Gateway\MyGateway::class;
return $gateways;
} );
The filter accepts class names (instantiated by WC) or instances. Class names are simpler.
Gateway class
namespace MyPlugin\Gateway;
class MyGateway extends \WC_Payment_Gateway {
public function __construct() {
$this->id = 'mygateway';
$this->method_title = __( 'My Gateway', 'myplugin' );
$this->method_description = __( 'Process payments via My Gateway.', 'myplugin' );
$this->has_fields = false;
$this->icon = plugins_url( 'assets/icon.png', MYPLUGIN_PLUGIN_FILE );
$this->supports = array( 'products', 'refunds' );
$this->init_form_fields();
$this->init_settings();
$this->title = $this->get_option( 'title' );
$this->description = $this->get_option( 'description' );
$this->enabled = $this->get_option( 'enabled' );
add_action(
'woocommerce_update_options_payment_gateways_' . $this->id,
array( $this, 'process_admin_options' )
);
add_action( 'woocommerce_api_mygateway', array( $this, 'handle_webhook' ) );
}
public function init_form_fields(): void {
$this->form_fields = array(
'enabled' => array(
'title' => __( 'Enable/Disable', 'myplugin' ),
'type' => 'checkbox',
'label' => __( 'Enable My Gateway', 'myplugin' ),
'default' => 'no',
),
'title' => array(
'title' => __( 'Title', 'myplugin' ),
'type' => 'text',
'description' => __( 'Shown to customers at checkout.', 'myplugin' ),
'default' => __( 'My Gateway', 'myplugin' ),
'desc_tip' => true,
),
'description' => array(
'title' => __( 'Description', 'myplugin' ),
'type' => 'textarea',
'default' => __( 'Pay securely via My Gateway.', 'myplugin' ),
),
'api_key' => array(
'title' => __( 'API key', 'myplugin' ),
'type' => 'password',
),
);
}
public function payment_fields(): void {
if ( $this->description ) {
echo wpautop( wp_kses_post( $this->description ) );
}
}
public function process_payment( $order_id ) {
$order = wc_get_order( $order_id );
if ( ! $order instanceof \WC_Order ) {
throw new \Exception( __( 'Invalid order.', 'myplugin' ) );
}
try {
$response = $this->call_api_charge( array(
'amount' => $order->get_total(),
'currency' => $order->get_currency(),
'order_id' => $order_id,
'api_key' => $this->get_option( 'api_key' ),
) );
} catch ( \Throwable $e ) {
wc_add_notice( __( 'Payment error: ', 'myplugin' ) . $e->getMessage(), 'error' );
return array( 'result' => 'failure' );
}
if ( $response['status'] === 'authorized' || $response['status'] === 'captured' ) {
$order->payment_complete( $response['transaction_id'] );
} elseif ( $response['status'] === 'pending' ) {
$order->update_status( 'on-hold', __( 'Awaiting payment confirmation.', 'myplugin' ) );
} else {
$order->update_status( 'failed', __( 'Payment failed: ', 'myplugin' ) . ( $response['message'] ?? '' ) );
return array( 'result' => 'failure' );
}
WC()->cart->empty_cart();
return array(
'result' => 'success',
'redirect' => $this->get_return_url( $order ),
);
}
public function process_refund( $order_id, $amount = null, $reason = '' ) {
$order = wc_get_order( $order_id );
if ( ! $order instanceof \WC_Order ) {
return new \WP_Error( 'invalid_order', __( 'Invalid order.', 'myplugin' ) );
}
$txn_id = $order->get_transaction_id();
if ( ! $txn_id ) {
return new \WP_Error( 'no_transaction', __( 'No transaction ID stored on this order.', 'myplugin' ) );
}
try {
$this->call_api_refund( array(
'transaction_id' => $txn_id,
'amount' => $amount,
'reason' => $reason,
) );
} catch ( \Throwable $e ) {
return new \WP_Error( 'refund_failed', $e->getMessage() );
}
$order->add_order_note(
sprintf( __( 'Refunded %s via My Gateway. Reason: %s', 'myplugin' ),
wc_price( $amount, array( 'currency' => $order->get_currency() ) ),
$reason
)
);
return true;
}
private function call_api_charge( array $params ): array { }
private function call_api_refund( array $params ): array { }
public function handle_webhook(): void { }
}
payment_complete() vs update_status() — when to use each
Both touch the order's status, but they're not interchangeable.
$order->payment_complete( $transaction_id ) (class-wc-order.php payment_complete method) — verified flow:
- Clears the
order_awaiting_payment session flag.
- Fires
woocommerce_pre_payment_complete action.
- Looks up the next status via
apply_filters( 'woocommerce_payment_complete_order_status', $needs_processing ? 'processing' : 'completed', $order_id, $order ).
- Records the transaction ID via
set_transaction_id.
- Sets the status (
set_status + save).
- Adds an order note (
"Payment via X (transaction Y)").
- Fires
woocommerce_payment_complete action — downstream listeners hook into THIS for "the order is paid" reactions.
$order->update_status( $status, $note ) is mechanical:
- Sets status → save → fires
woocommerce_order_status_<status> and woocommerce_order_status_<from>_to_<to> and woocommerce_order_status_changed.
- Does NOT clear the session flag.
- Does NOT record a transaction ID.
- Does NOT fire
woocommerce_payment_complete.
| Use | When |
|---|
payment_complete( $txn_id ) | Capture / charge succeeded synchronously. The "we have the money" moment. |
update_status( 'on-hold', $note ) | Bank transfer / cheque / awaiting webhook confirmation. Money will arrive later. |
update_status( 'processing', $note ) | Manual status flip in admin tool — NEVER from a successful charge. |
update_status( 'failed', $note ) | Provider declined / network error / fraud-decline. |
The woocommerce_payment_complete_order_status filter is the right place to override the resolved status (e.g. COD overrides to 'processing' because there's no actual money in hand yet).
The $supports array
$supports declares features your gateway implements. WC and ecosystem plugins (Subscriptions, Pre-Orders, Memberships) read this to decide whether to integrate.
Common values (verified in WC_Payment_Gateway::supports() and concrete gateways):
| Feature | Meaning |
|---|
'products' | Standard checkout. Default, included even if you don't declare. |
'refunds' | Implements process_refund. The "Refund" button appears in admin. |
'tokenization' | Stores tokens via WC_Payment_Tokens for saved-card / one-click checkout. |
'add_payment_method' | Lets user save a method outside the checkout flow (My Account → Payment Methods). |
'default_credit_card_form' | Legacy card form. Use the modern WC_Payment_Gateway_CC extension instead. |
'subscriptions' | Compatible with WC Subscriptions for renewal payments. |
'subscription_cancellation' / 'subscription_suspension' / 'subscription_reactivation' / 'subscription_amount_changes' / 'subscription_date_changes' / 'subscription_payment_method_change' | Subscriptions sub-features. Check WC Subscriptions docs for the exact gating. |
'multiple_subscriptions' | Handles checkout containing multiple subscriptions. |
'pre-orders' | WC Pre-Orders compat. |
Webhooks — the wc-api mechanism
For provider callbacks (Stripe webhook, PayPal IPN, bank notification), use the wc-api endpoint — WC's pre-REST callback URL system:
add_action( 'woocommerce_api_mygateway', array( $this, 'handle_webhook' ) );
public function handle_webhook(): void {
$payload = file_get_contents( 'php://input' );
if ( ! $this->verify_signature( $payload, $_SERVER['HTTP_X_PROVIDER_SIGNATURE'] ?? '' ) ) {
status_header( 401 );
wp_die( 'Invalid signature', '', array( 'response' => 401 ) );
}
$data = json_decode( $payload, true );
$order_id = (int) ( $data['metadata']['order_id'] ?? 0 );
$order = wc_get_order( $order_id );
if ( ! $order ) {
status_header( 404 );
exit;
}
if ( $data['event'] === 'payment.captured' && ! $order->is_paid() ) {
$order->payment_complete( $data['transaction_id'] );
} elseif ( $data['event'] === 'payment.failed' ) {
$order->update_status( 'failed', $data['message'] ?? '' );
}
status_header( 200 );
exit;
}
The webhook URL the provider should call is https://store.example/?wc-api=mygateway. The action hook name is woocommerce_api_<gateway_id> — slug must match.
For new code consider also exposing a REST endpoint via register_rest_route (see wp-rest-api skill); the wc-api mechanism predates REST and is being phased out long-term. Both work today.
Critical rules
process_payment returns array( 'result' => 'success', 'redirect' => $url ) on success, throws an exception or returns array( 'result' => 'failure' ) on error. Never return raw HTML, never wp_redirect from inside the method (WC handles the redirect from the return value).
payment_complete( $transaction_id ) for "money received" — NOT update_status('processing') / update_status('completed'). The latter skips session-flag cleanup, transaction-id storage, and the woocommerce_payment_complete action.
WC()->cart->empty_cart() after a successful process_payment. The built-in BACS / cheque / COD all do this. Skip it and the customer's cart still has the items they just bought.
$this->get_return_url( $order ) for the thank-you redirect. Don't construct your own URL — get_return_url honors site-specific overrides (custom thank-you pages, etc.).
- Only declare
'refunds' in $supports if you implement process_refund. Declaring without implementing leaves the admin with a broken refund button.
- Webhooks MUST verify signatures. No exceptions. The
wc-api endpoint is unauthenticated by default.
- Idempotent webhook handling. Providers retry on non-2xx. Check
$order->is_paid() before calling payment_complete() again on a re-delivered event.
woocommerce_payment_complete_order_status filter to override the resolved status. Used when a gateway naturally lands on a non-default status (COD → 'processing', even though needs_processing() would return false).
init_form_fields() declares admin settings, init_settings() reads them. Always pair both in the constructor.
- Settings save handler wiring:
add_action( 'woocommerce_update_options_payment_gateways_' . $this->id, array( $this, 'process_admin_options' ) ); — without this, settings page Save doesn't persist.
Common mistakes
public function process_payment( $order_id ) {
$order = wc_get_order( $order_id );
$this->call_api_charge( );
$order->update_status( 'completed' );
return array( 'result' => 'success', 'redirect' => $this->get_return_url( $order ) );
}
$order->payment_complete( $response['transaction_id'] );
WC()->cart->empty_cart();
public function process_payment( $order_id ) {
$order = wc_get_order( $order_id );
wp_redirect( $this->get_return_url( $order ) );
exit;
}
$this->supports = array( 'products', 'refunds' );
public function handle_webhook(): void {
$data = json_decode( file_get_contents( 'php://input' ), true );
$order = wc_get_order( $data['order_id'] );
$order->payment_complete();
}
public function handle_webhook(): void {
if ( ) {
$order->payment_complete( $data['txn'] );
}
}
return array( 'result' => 'success' );
public function __construct() {
$this->id = 'mygateway';
$this->init_form_fields();
}
Reading transaction IDs / payment state
$order = wc_get_order( $order_id );
$transaction_id = $order->get_transaction_id();
$payment_method = $order->get_payment_method();
$payment_title = $order->get_payment_method_title();
$is_paid = $order->is_paid();
$needs_payment = $order->needs_payment();
Storing additional gateway-specific data on the order: $order->update_meta_data( '_mygateway_capture_id', $value ); $order->save(); (HPOS-compatible). Don't use update_post_meta directly on order IDs — see wc-hpos-compatibility skill.
Cross-references
- Run
wc-hpos-compatibility when storing custom meta on orders — WC_Order::update_meta_data + save is the right path; direct postmeta calls break HPOS.
- Run
wc-stripe-add-payment-method when touching WooCommerce Stripe saved cards, My Account payment method templates, add-payment-method, SetupIntent, or Stripe billing-details/tokenization UI.
- Run
wp-security-audit on the webhook handler — it's an unauthenticated endpoint with attacker-controlled input. Signature verification + rate limiting + idempotency.
- Run
wp-security-secrets on API key storage — gateway secrets in autoloaded options is a smell; consider wp-config.php constants or per-environment config.
- Run
wp-rest-api if migrating webhooks from wc-api to a proper REST endpoint with permission_callback.
What this skill does NOT cover
- Block-based checkout integration (
@woocommerce/blocks-registry, registerPaymentMethod). Adjacent topic, separate React-side concern. Classic-shortcode checkout works the same way it did in WC 7.x.
- Subscriptions support beyond declaring
'subscriptions' in $supports. WC Subscriptions has its own gateway-extension docs.
- Stripe saved-card tokenization/account-template details — use
wc-stripe-add-payment-method.
- Currency conversion / multi-currency at the gateway level — usually a separate plugin handles this above the gateway.
- 3D Secure / SCA flow — provider-specific; the skill only covers the outer WC integration.
- Server-side certificate pinning for webhook delivery — niche.
References