Stop a third-party payment gateway charging real customers from a staging clone
A staging site is usually a byte-for-byte copy of production, live payment credentials included, so an automated path — a renewal cron, a replayed webhook, a manual retry — can charge real customers from the copy. Core protects its own Stripe calls by comparing the URL the install first recorded against the one it is running at now, and refuses to move money when the two differ.
Core cannot extend that protection to your gateway. A gateway registered through benecaster_payment_gateways never passes through core’s Stripe client, so nothing in core sits between your add-on and its API. Call the guard yourself before anything that moves money or changes a subscription’s lifecycle, and catch the refusal at every one of your own entry points.
How you catch it matters more than that you catch it. NonProductionChargeBlocked extends \RuntimeException, so a broad \RuntimeException or \Throwable arm placed first will swallow it and answer with whatever that arm says. Core shipped exactly this bug and corrected it at the batch-end review: the broad arm reported “Stripe is not configured”, sending the operator to check credentials that were fine. Catch the specific exception first.
Answer 409, never a 5xx. A refusal is a settled state — nothing about retrying changes the answer on a copy — and an uncaught exception inside a webhook handler becomes a 500, which a payment provider answers by retrying. The one thing worse than the charge you blocked is blocking it in a way that makes the provider try again all day.
Never pass the exception’s message back to a caller, as the example below deliberately does not. It names the recorded production URL, the current URL and the constant that disarms the guard — infrastructure detail on endpoints a logged-out visitor may be able to reach. Core shipped three boundaries that echoed it — signup, buy-up purchase and donation intent — and corrected all three. The operator’s copy of the same facts is already in the debug log.
The operation string you pass is what appears in that log, one line per operation per hour alongside the recorded and current URLs, so name it after the call rather than after your add-on. Reads and customer-record writes are deliberately left unguarded, matching core. A genuine production site that trips the check is re-enabled by adding define( 'BENECASTER_IS_PRODUCTION', true ); to wp-config.php — you do not need to handle that case yourself.
Code
<?php
use Benecaster\Payment\NonProductionChargeBlocked;
use Benecaster\Payment\OutboundChargeGuard;
add_action( 'benecaster_boot', function ( \Benecaster\Container $container ) {
$guard = $container->make( OutboundChargeGuard::class );
// Inside your gateway, before any charge or lifecycle call.
try {
$guard->assert_may_move_money( 'myaddon_create_subscription' );
$subscription = my_gateway_api()->createSubscription( $customer, $plan );
} catch ( NonProductionChargeBlocked $e ) {
// Refuse the way THIS entry point already refuses. A REST route
// returns WP_Error; a webhook returns 200 without acting, so the
// provider does not retry; cron logs and skips.
return new WP_Error(
'myaddon_billing_unavailable',
__( 'Billing is unavailable on this site.', 'my-addon' ),
[ 'status' => 409 ]
);
}
} );
Hooks Used
Need this built rather than just documented? See our services →