Create retry
Repeat the same order after an HTTP timeout and confirm one provider order is reused.
WordPress · WooCommerce · PHP 8.1+
Register a redirect gateway, create the payment from trusted order data, store the provider reference, and complete the WooCommerce order only after HMAC verification.
Prerequisites
The implementation below targets WooCommerce classic checkout. `WC_Payment_Gateway` alone does not register a method in Checkout Blocks. If the store uses blocks, implement WooCommerce’s separate Blocks payment-method integration or intentionally use the classic checkout shortcode.
Use HTTPS, current WordPress and WooCommerce releases, PHP 8.1 or newer, a supported connected merchant provider account, a test API key, and a test webhook secret. Back up and stage the store before enabling a new payment method.
Step 1
Copy the exact environment base URL and test credentials from the dashboard. Never commit real values. A managed host’s secret store is preferable; `wp-config.php` is the minimum server-side boundary shown here.
// wp-config.php — keep above "That's all, stop editing"
define('VG_BASE_URL', 'https://base-url-from-your-dashboard.example');
define('VG_API_KEY', 'vg_test_replace_with_dashboard_key');
define('VG_WEBHOOK_SECRET', 'whsec_test_replace_with_dashboard_secret'); Steps 2–3
Create `wp-content/plugins/vyapargateway-upi/vyapargateway-upi.php` with this complete classic-checkout gateway. The stable `client_txn_id` makes a retry for the same WooCommerce order idempotent.
<?php
/**
* Plugin Name: VyaparGateway UPI for WooCommerce
* Description: Redirect-based UPI checkout with signed payment callbacks.
* Version: 1.0.0
* Requires Plugins: woocommerce
*/
defined('ABSPATH') || exit;
add_action('before_woocommerce_init', static function (): void {
if (class_exists(\Automattic\WooCommerce\Utilities\FeaturesUtil::class)) {
\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility(
'custom_order_tables',
__FILE__,
true
);
}
});
add_filter('woocommerce_payment_gateways', static function (array $gateways): array {
$gateways[] = 'WC_Gateway_VyaparGateway_UPI';
return $gateways;
});
add_action('plugins_loaded', static function (): void {
if (!class_exists('WC_Payment_Gateway')) {
return;
}
final class WC_Gateway_VyaparGateway_UPI extends WC_Payment_Gateway
{
public function __construct()
{
$this->id = 'vyapargateway_upi';
$this->method_title = 'VyaparGateway UPI';
$this->method_description =
'Creates a hosted UPI payment order and verifies payment by webhook.';
$this->has_fields = false;
$this->supports = ['products'];
$this->init_form_fields();
$this->init_settings();
$this->title = (string) $this->get_option('title');
$this->description = (string) $this->get_option('description');
$this->enabled = (string) $this->get_option('enabled');
add_action(
'woocommerce_update_options_payment_gateways_' . $this->id,
[$this, 'process_admin_options']
);
}
public function init_form_fields(): void
{
$this->form_fields = [
'enabled' => [
'title' => 'Enable/Disable',
'type' => 'checkbox',
'label' => 'Enable VyaparGateway UPI',
'default' => 'no',
],
'title' => [
'title' => 'Checkout title',
'type' => 'text',
'default' => 'UPI',
'desc_tip' => true,
],
'description' => [
'title' => 'Checkout description',
'type' => 'textarea',
'default' => 'Pay securely with a supported UPI app.',
],
];
}
public function process_payment($order_id): array
{
if (
!defined('VG_BASE_URL') ||
!defined('VG_API_KEY') ||
VG_BASE_URL === '' ||
VG_API_KEY === ''
) {
wc_add_notice(
'UPI checkout is not configured. Please choose another method.',
'error'
);
return ['result' => 'failure'];
}
$order = wc_get_order($order_id);
if (!$order instanceof WC_Order) {
wc_add_notice('Unable to load the order.', 'error');
return ['result' => 'failure'];
}
$client_txn_id = 'WC-' . $order->get_id();
$body = [
'client_txn_id' => $client_txn_id,
'amount' => wc_format_decimal($order->get_total(), 2),
'p_info' => 'WooCommerce order ' . $order->get_order_number(),
'customer_name' => trim(
$order->get_billing_first_name() . ' ' .
$order->get_billing_last_name()
),
'customer_email' => $order->get_billing_email(),
'customer_mobile' => $order->get_billing_phone(),
'redirect_url' => $this->get_return_url($order),
'callback_url' => rest_url('vyapargateway/v1/webhook'),
];
$response = wp_remote_post(
untrailingslashit(VG_BASE_URL) . '/api/v1/create_order',
[
'timeout' => 15,
'redirection' => 0,
'headers' => [
'Content-Type' => 'application/json',
'X-API-Key' => VG_API_KEY,
],
'body' => wp_json_encode($body),
]
);
if (is_wp_error($response)) {
wc_add_notice(
'UPI checkout is temporarily unavailable. Please retry.',
'error'
);
return ['result' => 'failure'];
}
$status_code = wp_remote_retrieve_response_code($response);
$payload = json_decode(
wp_remote_retrieve_body($response),
true,
512,
JSON_THROW_ON_ERROR
);
if (
$status_code < 200 ||
$status_code >= 300 ||
empty($payload['status']) ||
empty($payload['data']['order_id']) ||
empty($payload['data']['payment_url']) ||
($payload['data']['client_txn_id'] ?? '') !== $client_txn_id
) {
wc_add_notice('The payment order was rejected.', 'error');
return ['result' => 'failure'];
}
$order->update_meta_data(
'_vg_order_id',
sanitize_text_field($payload['data']['order_id'])
);
$order->update_meta_data('_vg_client_txn_id', $client_txn_id);
$order->update_status('pending', 'Awaiting verified UPI payment.');
$order->save();
return [
'result' => 'success',
'redirect' => esc_url_raw($payload['data']['payment_url']),
];
}
}
}); Catch `JsonException` around malformed provider responses in a hardened plugin and log a redacted correlation ID through `wc_get_logger()`. Do not log the API key or full customer payload.
Step 4
Add this code in the same plugin file after the gateway registration. The route is publicly reachable because the sender is a payment service, but the timestamp and HMAC authenticate each request.
add_action('rest_api_init', static function (): void {
register_rest_route('vyapargateway/v1', '/webhook', [
'methods' => 'POST',
'callback' => 'vg_handle_payment_webhook',
'permission_callback' => '__return_true',
]);
});
function vg_handle_payment_webhook(WP_REST_Request $request): WP_REST_Response
{
if (!defined('VG_WEBHOOK_SECRET') || VG_WEBHOOK_SECRET === '') {
return new WP_REST_Response(['error' => 'Not configured'], 503);
}
$raw_body = $request->get_body();
$timestamp = (string) $request->get_header(
'x-vyapargateway-timestamp'
);
$received = strtolower((string) $request->get_header(
'x-vyapargateway-signature'
));
if (
!ctype_digit($timestamp) ||
abs(time() - (int) $timestamp) > 300 ||
!preg_match('/^[a-f0-9]{64}$/', $received)
) {
return new WP_REST_Response(['error' => 'Invalid signature input'], 401);
}
$expected = hash_hmac(
'sha256',
$timestamp . '.' . $raw_body,
VG_WEBHOOK_SECRET
);
if (!hash_equals($expected, $received)) {
return new WP_REST_Response(['error' => 'Invalid signature'], 401);
}
try {
$event = json_decode($raw_body, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
return new WP_REST_Response(['error' => 'Invalid JSON'], 400);
}
$client_txn_id = (string) ($event['client_txn_id'] ?? '');
if (!preg_match('/^WC-(\d+)$/', $client_txn_id, $matches)) {
return new WP_REST_Response(['error' => 'Unknown order reference'], 400);
}
$order = wc_get_order((int) $matches[1]);
if (!$order instanceof WC_Order) {
return new WP_REST_Response(['error' => 'Order not found'], 404);
}
$provider_order_id = (string) ($event['order_id'] ?? '');
$stored_provider_id = (string) $order->get_meta('_vg_order_id', true);
$expected_amount = wc_format_decimal($order->get_total(), 2);
$received_amount = wc_format_decimal(
(string) ($event['amount'] ?? ''),
2
);
if (
$provider_order_id === '' ||
!hash_equals($stored_provider_id, $provider_order_id) ||
($event['currency'] ?? '') !== 'INR' ||
$expected_amount !== $received_amount
) {
return new WP_REST_Response(['error' => 'Order mismatch'], 409);
}
if (($event['status'] ?? '') !== 'success') {
return new WP_REST_Response(['received' => true], 200);
}
if (!$order->is_paid()) {
$transaction_id = sanitize_text_field(
(string) ($event['upi_txn_id'] ?? $provider_order_id)
);
$order->payment_complete($transaction_id);
$order->add_order_note(
'UPI payment verified by signed VyaparGateway webhook.'
);
$order->save();
}
return new WP_REST_Response(null, 204);
} Test matrix
Repeat the same order after an HTTP timeout and confirm one provider order is reused.
Change one payload byte, timestamp, and signature; each request must return 401.
Send a validly signed event with the wrong amount and confirm the order stays pending.
Deliver the same success twice and confirm stock and fulfilment run once.
Load the thank-you URL before the webhook and display a truthful pending state.
Authorise near expiry and reconcile before marking the order permanently failed.
Confirm existing paid orders remain queryable and support can reconcile references.
Confirm the gateway is not silently missing when the store uses Checkout Blocks.
Questions
Enable the method for a controlled cohort before all store traffic.