Treat this lesson with the weight it deserves: a refund moves real money out of a real payment gateway, and once that call succeeds, it is not something you casually undo. Of everything built so far in this course, this is the first ability that can directly cost your business money if it runs wrong, whether that’s an agent misreading an amount, a prompt injection buried in an order note, or a person confirming something they misunderstood. Nothing here treats that lightly. The refund ability in this lesson is confirmation-gated by default with no bypass, and refunds above a threshold you control require a second, distinct human to sign off, not just the same person twice.
Lesson 2’s propose/confirm pattern for orders. A WooCommerce install with at least one
gateway configured that supports programmatic refunds if you intend to test
refund_payment => true against anything beyond a sandbox account. Never test this
against a live payment gateway with real customer money.
When refund_payment is true, wc_create_refund() calls the active payment gateway’s
own refund API. That is a genuine, gateway-side financial transaction, not a WordPress
database update. Test everything in this lesson against a gateway’s sandbox or test mode
first, and never against a live account, until you’ve verified every path, including the
failure paths, behaves exactly as you expect.
Step 1: What wc_create_refund() actually accepts and returns
wc_create_refund( $args ) is a real, current WooCommerce function. Its accepted
$args keys, with their defaults, are:
| Key | Default | What it does |
|---|---|---|
amount | 0 | Total refund amount. Can override the sum of line-item refunds. |
reason | null | Text reason, stored on the refund and visible in order notes. |
order_id | 0 | The order being refunded. |
line_items | array() | Per-item refund amounts, for partial, line-level refunds. |
refund_payment | false | If true, calls the active payment gateway’s refund API, real money movement. If false, records the refund without contacting the gateway. |
restock_items | false | If true, returns refunded line-item quantities to stock. |
It returns a WC_Order_Refund object on success, or a WP_Error on failure. Always
check for WP_Error before treating a call as successful.
WooCommerce’s REST API v3 order-refunds endpoint uses api_refund and api_restock
(both default true there). The PHP function wc_create_refund() you’re calling
directly in this lesson uses refund_payment and restock_items (both default false
here). These are two different interfaces to related but distinct behavior, don’t copy
argument names from REST API documentation into a direct PHP call, or vice versa, they
will silently be ignored rather than throwing an error.
Step 2: The propose step, computing the amount and checking the threshold
// File: my-plugin.php
define( 'MY_PLUGIN_REFUND_SECOND_APPROVER_THRESHOLD', 100.00 );
wp_register_ability( 'my-plugin/propose-refund', array(
'label' => __( 'Propose a refund', 'my-plugin' ),
'description' => __( 'Describes a refund for a specific order and amount, and returns a confirmation token. Does not refund anything. Refunds at or above the store threshold require a second, different approving user.', 'my-plugin' ),
'input_schema' => array(
'type' => 'object',
'properties' => array(
'order_id' => array( 'type' => 'integer' ),
'amount' => array( 'type' => 'number' ),
'reason' => array( 'type' => 'string' ),
'refund_payment' => array( 'type' => 'boolean', 'default' => true ),
'restock_items' => array( 'type' => 'boolean', 'default' => true ),
),
'required' => array( 'order_id', 'amount', 'reason' ),
),
'permission_callback' => function () {
return current_user_can( 'manage_woocommerce' );
},
'execute_callback' => function ( $input ) {
$order = wc_get_order( $input['order_id'] );
if ( ! $order ) {
return new WP_Error( 'not_found', 'No order with that ID exists.' );
}
if ( (float) $input['amount'] > (float) $order->get_total() ) {
return new WP_Error( 'amount_too_large', 'Refund amount exceeds the order total.' );
}
$needs_second_approver = (float) $input['amount'] >= MY_PLUGIN_REFUND_SECOND_APPROVER_THRESHOLD;
$token = wp_generate_password( 24, false );
set_transient(
'agent_refund_token_' . $token,
array(
'order_id' => $order->get_id(),
'amount' => (float) $input['amount'],
'reason' => sanitize_text_field( $input['reason'] ),
'refund_payment' => (bool) $input['refund_payment'],
'restock_items' => (bool) $input['restock_items'],
'proposing_user_id' => get_current_user_id(),
'needs_second_approver' => $needs_second_approver,
),
10 * MINUTE_IN_SECONDS
);
return array(
'order_id' => $order->get_id(),
'amount' => (float) $input['amount'],
'effect' => $needs_second_approver
? 'This refund is at or above the store\'s review threshold and requires sign-off from a different manager than the one proposing it.'
: 'This will refund the order and, if refund_payment is true, contact the payment gateway for a real transaction.',
'requires_second_approver' => $needs_second_approver,
'confirmation_token' => $token,
);
},
'meta' => array( 'mcp' => array( 'public' => false ) ),
) );
Step 3: The confirm step, enforcing the second-approver rule with no bypass
// File: my-plugin.php
wp_register_ability( 'my-plugin/confirm-refund', array(
'label' => __( 'Confirm and execute a proposed refund', 'my-plugin' ),
'description' => __( 'Executes a refund previously described by my-plugin/propose-refund. Refunds above the store threshold require a confirming user different from the proposing one.', 'my-plugin' ),
'input_schema' => array(
'type' => 'object',
'properties' => array( 'confirmation_token' => array( 'type' => 'string' ) ),
'required' => array( 'confirmation_token' ),
),
'permission_callback' => function () {
return current_user_can( 'manage_woocommerce' );
},
'execute_callback' => function ( $input ) {
$key = 'agent_refund_token_' . $input['confirmation_token'];
$pending = get_transient( $key );
if ( ! $pending ) {
return new WP_Error( 'invalid_or_expired_token', 'This confirmation has expired or was never issued.' );
}
if ( $pending['needs_second_approver']
&& $pending['proposing_user_id'] === get_current_user_id() ) {
return new WP_Error(
'second_approver_required',
'This refund amount requires sign-off from a manager other than the one who proposed it. Have a different manager confirm it.'
);
}
delete_transient( $key ); // one-time use, invalidated before the refund runs
$refund = wc_create_refund( array(
'order_id' => $pending['order_id'],
'amount' => $pending['amount'],
'reason' => $pending['reason'],
'refund_payment' => $pending['refund_payment'],
'restock_items' => $pending['restock_items'],
) );
if ( is_wp_error( $refund ) ) {
return $refund;
}
$order = wc_get_order( $pending['order_id'] );
$order->add_order_note( sprintf(
'Agent-initiated refund of %s confirmed by user #%d. Reason: %s',
wc_price( $pending['amount'] ),
get_current_user_id(),
$pending['reason']
) );
return array( 'refunded' => true, 'refund_id' => $refund->get_id(), 'amount' => $pending['amount'] );
},
'meta' => array( 'mcp' => array( 'public' => false ) ),
) );
Notice that MY_PLUGIN_REFUND_SECOND_APPROVER_THRESHOLD is a hard-coded constant, not a
site option an agent, or a compromised admin session, could quietly lower. If you want it
configurable, expose it through a filter that only runs in PHP you control, never through
any input an ability itself accepts.
Two different WordPress accounts that both happen to have the manage_woocommerce
capability still satisfy this check technically, even if the same person is logged into
both. This is a real limitation of an automated check: it enforces that two distinct
accounts were involved, not that two distinct humans made an independent judgment. Pair
this with your store’s actual account-hygiene policy (one person, one account) for the
rule to mean what it’s meant to mean.
Step 4: Exchanges, a real replacement order with wc_create_order()
Not every return is a refund. Sometimes the right outcome is a replacement item, which
means creating a real new order rather than pretending a refund and an exchange are the
same operation. wc_create_order( $args ) accepts status, customer_id,
customer_note, parent, created_via, and cart_hash (all default null), plus
order_id (default 0), and returns a WC_Order object on success or a WP_Error on
failure:
// File: my-plugin.php
function my_plugin_create_exchange_order( $original_order_id, $replacement_product_id, $qty = 1 ) {
$original = wc_get_order( $original_order_id );
if ( ! $original ) {
return new WP_Error( 'not_found', 'Original order not found.' );
}
$product = wc_get_product( $replacement_product_id );
if ( ! $product ) {
return new WP_Error( 'not_found', 'Replacement product not found.' );
}
$new_order = wc_create_order( array(
'customer_id' => $original->get_customer_id(),
'customer_note' => 'Exchange replacement for order #' . $original->get_id(),
'created_via' => 'agent-exchange',
) );
if ( is_wp_error( $new_order ) ) {
return $new_order;
}
$new_order->add_product( $product, $qty );
$new_order->calculate_totals();
$new_order->update_status( 'pending', 'Created by agent-assisted exchange flow, awaiting manual review before fulfillment.' );
$original->add_order_note( sprintf( 'Exchange replacement order #%d created.', $new_order->get_id() ) );
return array( 'new_order_id' => $new_order->get_id(), 'status' => $new_order->get_status() );
}
This function only creates a pending order, it does not take payment, ship anything,
or refund the original order automatically. Wire it behind the same propose/confirm
pattern as the refund ability above if you expose it to an agent, an exchange still
commits real inventory and, usually, a decision not to charge the customer again for the
replacement item.
Test it
Against a sandbox gateway account only:
wp-env run cli wp eval '
$propose = wp_get_ability( "my-plugin/propose-refund" )->execute( array( "order_id" => 101, "amount" => 25.00, "reason" => "Damaged on arrival" ) );
print_r( $propose );
'
Confirm with the token from the same user for a small amount, and separately verify a refund at or above your threshold is rejected when the same user proposes and confirms it, and only succeeds when a second, different manager account confirms it.
wc_create_refund() returns WP_Error, not a thrown exception, on failure (an invalid
order, an amount exceeding the order total, a gateway rejecting the refund call). Code
that assumes success and calls $refund->get_id() without checking is_wp_error()
first will fatal on exactly the failures you most need to see reported clearly.
Recap
A refund is real money leaving a real gateway, and this lesson’s ability treats it that
way at every step: wc_create_refund() called only after a propose/confirm round trip,
a hard-coded threshold above which the confirming user must differ from the proposing
one, and no configuration path that skips either check. Exchanges get their own path
through wc_create_order(), building a genuine replacement order rather than conflating
“send the money back” with “send a different item instead.”
Resources & further reading
- WooCommerce order-refunds REST API reference, developer.woocommerce.com
- wc-order-functions.php source, GitHub
- WooCommerce order functions code reference, woocommerce.github.io
- Designing Confirmation-Gated Agent Actions, this site