Available — Local Digital Products
HCA · Studio
TR Contact ↗
Technical Note 01 · WooCommerce

Adding meal-card payments to WooCommerce

In Turkey, a site taking online food orders that cannot accept meal cards loses a large share of its audience at the payment step. WooCommerce does not support these cards out of the box; the answer is a custom payment gateway.

Topic
Payment gateway development
Platform
WooCommerce
Level
Intermediate — advanced
Short answer

To accept Multinet, Sodexo or Setcard in WooCommerce there is no off-the-shelf plugin; you write a custom gateway extending WC_Payment_Gateway against the provider's integration documentation. The critical point is never trusting the parameters on the URL the customer returns to: the payment must be confirmed by a separate server-to-server query on return. Only after that confirmation should the order be marked paid.

Why there is no ready plugin

WooCommerce's payment ecosystem is built largely around international providers and locally common virtual POS providers. Meal-card providers sit in a different category: contract-bound, requiring merchant approval, and sharing integration documentation only after an agreement.

So you will not find an install-and-go solution in the plugin directory. Be sceptical of plugins claiming otherwise, too: the payment flow depends on the provider's own API version and on merchant parameters specific to you.

The first step is commercial, not technical: sign the merchant agreement, obtain test credentials and the integration documentation. Code written without them is guesswork.

The gateway skeleton

A WooCommerce payment method is a class extending WC_Payment_Gateway, registered through the woocommerce_payment_gateways filter.

add_filter( 'woocommerce_payment_gateways', function ( $gateways ) {
    $gateways[] = 'WC_Gateway_Meal_Card';
    return $gateways;
} );

class WC_Gateway_Meal_Card extends WC_Payment_Gateway {

    public function __construct() {
        $this->id                 = 'meal_card';
        $this->method_title       = 'Meal Card';
        $this->method_description = 'Pay with a meal card.';
        $this->has_fields         = false;

        $this->init_form_fields();
        $this->init_settings();

        $this->title   = $this->get_option( 'title' );
        $this->enabled = $this->get_option( 'enabled' );

        add_action( 'woocommerce_update_options_payment_gateways_' . $this->id,
            array( $this, 'process_admin_options' ) );
    }

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

        // Start the transaction with the provider and get a redirect URL.
        $redirect = $this->start_transaction( $order );

        if ( is_wp_error( $redirect ) ) {
            wc_add_notice( $redirect->get_error_message(), 'error' );
            return array( 'result' => 'failure' );
        }

        return array(
            'result'   => 'success',
            'redirect' => $redirect,
        );
    }
}

Note what this does not do: process_payment does not mark the order paid. It only sends the customer to the provider's verification screen. Whether payment happened is unknown at this stage.

The payment flow

Meal-card integrations are typically redirect-based:

  1. The customer selects the meal card at checkout and places the order.
  2. The site sends a transaction-start request to the provider from the server, carrying the amount, an order reference and a return URL.
  3. The provider returns a transaction id and a redirect URL.
  4. The customer completes verification on the provider's screen.
  5. The provider sends the customer back to the return URL.
  6. The site makes a second, server-to-server query asking the provider for the real transaction state.
  7. If approved, the order is marked paid and moves to preparation.

Step six is what this note is about.

The most common security mistake

The most frequent error I see: the provider returns the customer to something like /payment-return/?status=success&order=1234, and the code reads status=success and treats the order as paid.

That URL passes through the customer's browser. Anyone who types the parameter by hand can mark an order paid without paying. In restaurant orders the consequence is direct loss of goods — the order reaches the kitchen, gets prepared and gets delivered.

The correct behaviour: the return step only tells you the customer came back. The real decision is asked of the provider.

public function handle_return() {
    $order_id = absint( $_GET['order_id'] ?? 0 );
    $order    = wc_get_order( $order_id );

    if ( ! $order || $order->is_paid() ) {
        return; // missing, or already paid — do not process twice
    }

    // The decision comes from the provider, not the browser:
    $status = $this->query_transaction_status( $order->get_id() );

    if ( is_wp_error( $status ) || 'approved' !== $status['state'] ) {
        $order->update_status( 'failed', 'Meal card payment was not approved.' );
        wp_safe_redirect( wc_get_checkout_url() );
        exit;
    }

    // Verify the amount too — never accept an approval for less.
    if ( ! $this->amounts_match( $status['amount'], $order->get_total() ) ) {
        $order->update_status( 'on-hold', 'Amount mismatch, needs manual review.' );
        return;
    }

    $order->payment_complete( $status['transaction_id'] );
    wp_safe_redirect( $this->get_return_url( $order ) );
    exit;
}

Three protections at once: the state is queried from the provider, the amount is compared, and an already-paid order is not reprocessed. That last one matters — refreshing the return page or a repeated provider callback must not complete the order twice.

Order states

Using WooCommerce order states correctly is half the integration, for accounting and operations alike:

StateWhenWhy
pendingOrder created, payment startedNo money yet; must not reach the kitchen
failedProvider declined or customer cancelledCustomer can retry
on-holdAmbiguity such as an amount mismatchNo automatic decision; a human must look
processingPayment confirmedpayment_complete() sets this itself

A common mistake is marking payment with update_status('processing') by hand. Use payment_complete() instead: it records the transaction id, reduces stock, fires the relevant hooks and picks the correct state for the store type.

Block checkout compatibility

WooCommerce's block-based checkout works differently from the classic shortcode page: payment methods are listed on the React side. A gateway written the classic way may not appear at all in block checkout.

add_action( 'woocommerce_blocks_loaded', function () {
    if ( ! class_exists( 'Automattic\\WooCommerce\\Blocks\\Payments\\Integrations\\AbstractPaymentMethodType' ) ) {
        return;
    }
    require_once __DIR__ . '/class-meal-card-blocks.php';

    add_action(
        'woocommerce_blocks_payment_method_type_registration',
        function ( $registry ) {
            $registry->register( new WC_Meal_Card_Blocks_Support() );
        }
    );
} );

Because the block side registers via JavaScript, the gateway needs a small JS file too. That file must not be deferred or concatenated — otherwise the payment method never appears in the list. See the empty cart note for the details of that trap.

Testing and going live

  • Start with the test environment. Using the provider's test merchant credentials, cover at least: successful payment, insufficient balance, customer abandonment, and refreshing the return page.
  • Insufficient balance is the critical case. Meal-card balances are often below the order total. The customer needs a message that explains what happened, the order should end up failed, and the cart must be preserved.
  • Keep records. Write a summary of every request to and from the provider into the WooCommerce order notes. In a payment dispute those notes are your only evidence.
  • Keep secrets out of code. Merchant keys belong in plugin settings or wp-config.php constants, never in version control.

Summary

A meal-card integration is a standard application of writing a WooCommerce payment gateway; the difficulty lies in reading the provider's documentation correctly and putting the trust boundary in the right place. Nothing arriving from the customer's browser is proof of payment. An order should be marked paid only after a server-to-server confirmed approval.

This approach runs live in the Her Mutfak ordering system.

Checklist
Prerequisite
Merchant agreement with the provider + integration documentation
Class
Extend WC_Payment_Gateway, register via woocommerce_payment_gateways
Trust boundary
NEVER trust return-URL parameters — query the provider for state
Amount check
Compare the approved amount against the order total
Replay guard
Do not reprocess an order that is already paid
Completion
Use payment_complete() rather than update_status
Block checkout
Requires separate block registration; the JS file must not be deferred
Secrets
Keys in settings or wp-config constants, never in code
Frequently Asked Questions

About meal-card integration.

Is there a ready plugin to add Multinet payments to WooCommerce?

In practice, no. Meal-card providers share integration details only with businesses that hold a merchant agreement, and the parameters are business-specific. The solution is a custom payment gateway extending WC_Payment_Gateway, built against the provider's documentation.

Why can't I trust the parameters on the return URL after payment?

Because that URL passes through the customer's browser and can be edited. A system that only reads 'success' from the URL allows orders to be created without any payment. The correct method is a separate server-to-server query on return, verifying both the transaction state and the amount.

My payment method doesn't appear in block checkout — why?

Block-based checkout lists payment methods on the JavaScript side. A gateway written for classic checkout must also register block support. Additionally, if the gateway's block JS file is deferred or concatenated by an optimisation plugin, the method never appears in the list.

Why use payment_complete() instead of setting the status to processing?

payment_complete() does more than change status: it records the transaction id, reduces stock, fires the relevant hooks and selects the correct order state for the store type. Setting the status manually skips those steps and leaves silent errors in reporting and stock.

Availability and Quotes

Visible in local search.
Credible in the product.