GitHub Markdown

Tokenized direct-post card checkout for WooCommerce, on the Inovio gateway. The card number never reaches the WordPress server.

Overview and status

Functional pre-release

Sale, authorize, capture, void, refund, vault and 3-D Secure are implemented and have been exercised end to end against the gateway. A 10-spec Playwright suite drives the real storefront and the real wp-admin. Not yet hardened for production.

The plugin was ported from the PrestaShop module, which carries the full design rationale. What makes the WooCommerce build different from its siblings is that WooCommerce has a native payment-token API, so saved cards use the platform's own vault with no custom UI, and that WooCommerce ships two entirely different checkouts. Both are supported.

Requirements

WordPress 6.0+
WooCommerce 7.0+. Block Checkout support needs 8.3+
PHP 8.0+
PHP extensions bcmath (required), curl, json
From Inovio API username, API password, Site ID, Site Key, Gateway Product ID

bcmath is required, not optional. The SDK's Money type does every decimal calculation through it so amounts never touch a binary float. Installation fails without it.

Install

Copy the inovio-payment-gateway/ directory into wp-content/plugins/, or upload the ZIP through Plugins, Add New, Upload Plugin. Then activate it.

Configure it under WooCommerce, Settings, Payments, Inovio.

wp plugin activate inovio-payment-gateway

Configuration

All settings live on the Inovio payment method screen under WooCommerce, Settings, Payments.

Gateway credentials

Setting Required Meaning
Enable/Disable Yes Turns the payment method on.
Title No The payment method name shoppers see at checkout. Defaults to "Credit card".
Description No Shown beneath the payment method name at checkout.
API Username Yes Gateway REQ_USERNAME.
API Password Yes Gateway REQ_PASSWORD.
Site ID Yes Gateway SITE_ID.
Merchant Account ID No MERCH_ACCT_ID. Leave empty to let the gateway distribute by currency and country.
Site Key Yes The per-site HMAC secret. Not the API password.
Gateway Product ID Yes LI_PROD_ID. Not a WooCommerce SKU.
Gateway Endpoint No The pmt_service.cfm transaction URL. The tokenization and 3-D Secure endpoints are derived from it, exactly as the SDK derives them, so the three cannot drift apart. Leave it at the default for production.

Site Key is a separate per-site HMAC secret issued by Inovio support. It is not the API password, and it is not something you can generate. It is used only to sign the browser's tokenization request. Without it the browser cannot tokenize and checkout fails with error 121.

Gateway Product ID (LI_PROD_ID) is a product registered on the gateway, not a product or SKU from your WooCommerce catalogue. The whole order bills as one line item under it.

Until API Username, API Password, Site ID, Site Key and Gateway Product ID are all filled in, is_configured() returns false and the method hides itself at checkout rather than presenting a card form it cannot process.

Payment behaviour settings

Setting Required Meaning
Payment Action Yes Sale charges at checkout. Authorize only reserves the funds and leaves the order on hold until you capture it from the order screen.
3-D Secure No Enables 3-D Secure authentication. Requires a 3DS-configured merchant account. Off by default.
Saved Cards No Lets logged-in shoppers save cards for reuse. On by default. Stores the gateway's own card references only, never a card number.
Statement Descriptor No PMT_DESCRIPTOR, what appears on the cardholder's statement. See the warning below.
Descriptor Phone No PMT_DESCRIPTOR_PHONE, support number shown alongside the descriptor.
Debug Logging No Logs gateway activity to WooCommerce, Status, Logs. Declines and errors are always logged. Card numbers are never logged, because they never reach this server.
The statement descriptor must not contain a space, underscore or slash

The gateway rejects the entire transaction with Invalid Data if PMT_DESCRIPTOR contains a space, an underscore or a forward slash. Every sale fails, not just the descriptor.

Descriptor Result
ACME STORE Rejected
ACME_STORE Rejected
ACME/STORE Rejected
ACME-STORE Accepted
ACMESTORE Accepted
ACME.STORE Accepted

The full allowed set is A-Z, a-z, 0-9, and . - * + & @. This was mapped empirically by sending each candidate character in an otherwise-identical approved request; the SDK now rejects an invalid descriptor rather than letting the gateway kill the sale. If you are unsure, leave the field empty.

Payment behaviour

Capability Support
Sale Authorize and capture in one step at checkout.
Authorize only Reserve funds, capture later from the order screen.
Capture Admin order action when the payment action is authorize-only.
Void Reverse an uncaptured authorization, from the order screen.
Refund Full and partial, through WooCommerce's own admin refund UI (process_refund()). Settlement-aware.
Saved cards Native WC_Payment_Tokens. Saved cards appear in the shopper's account and at checkout with no custom UI.
3-D Secure Device-data collection and challenge, two-token flow.
Checkout Both the classic [woocommerce_checkout] shortcode and the Block Checkout (woocommerce/checkout). A default WooCommerce install works out of the box.

Refunds follow the shared settlement-aware contract described in Refunds are settlement-aware. A full refund issues one reverseCapture call with creditOnFail, and the gateway decides between reversing and crediting. A partial refund issues refund and fails loudly with service code 536 if the order has not settled yet; retry it after settlement.

A gateway timeout means the transaction state is genuinely unknown. The plugin reconciles via status() before failing an order, including on the 3-D Secure completion leg, rather than letting the shopper retry into a possible double charge.

How checkout works on this platform

The direct-post flow

Card fields live in the checkout page's DOM. The shipped JavaScript reads them, asks the plugin for an HMAC signature, exchanges the PAN for a single-use TOKEN_GUID by POSTing directly to Inovio, and submits only that token to WordPress. Nothing in the payment path on your server ever sees a card number.

This holds identically on both checkout types, because both scripts tokenize the same way and both hand the same field names to the same process_payment().

No Inovio-hosted infrastructure is required: there is no hosted payment page and no iframe you have to redirect to.

Saved cards store the gateway's own references (CUST_ID, PMT_ID) plus the card brand and last four digits, which are display-only fields. No PAN is ever stored. Orders store only gateway references (_inovio_po_id, _inovio_trans_id, _inovio_req_id).

This was verified, not assumed: a full mysqldump of the WordPress database taken after a live test run contains zero occurrences of either test card number, in any representation (plain, spaced, dashed).

Two checkouts, one server implementation

A default WooCommerce install of 8.3 or later puts the Block Checkout on the checkout page, not the classic shortcode. WC_Payment_Gateway classes are invisible to it on their own, so the plugin also registers Inovio_Blocks_Support (extending Automattic\WooCommerce\Blocks\Payments\Integrations\AbstractPaymentMethodType) on the woocommerce_blocks_payment_method_type_registration hook, and ships a second checkout script registered via wc.wcBlocksRegistry.registerPaymentMethod.

That script performs the same direct-post tokenization as the classic script. The tokenize, validate and 3DS helper functions are deliberately mirrored between the two files rather than shared, because the Block Checkout and the classic checkout are different JS runtimes with different script-dependency graphs and there is no bundler in this plugin. If you change one, keep the behaviour identical in the other.

Server-side there is only one implementation. Inovio_Payment_Gateway::process_payment() is unchanged. Automattic\WooCommerce\StoreApi\Legacy::process_legacy_payment() sets $_POST from the Blocks paymentMethodData before calling it, so collect_payment_data()'s existing $_POST reads pick up the Blocks-minted tokens exactly as they pick up the classic form's hidden fields. Same field names, same code path, same PAN-safety guarantees.

3-D Secure on the Block Checkout

The Store API's payment_result.payment_details response schema types every value as a plain string (CheckoutSchema::get_item_schema()). A nested-array 3DS payload gets silently coerced by WordPress's REST schema sanitizer into the literal string "Array" on the Blocks path, a fully swallowed failure with no error anywhere. This was verified empirically. The contract for both checkout types is therefore that inovio_3ds travels as a JSON string, parsed back into an object by whichever checkout script reads it.

The Blocks 3DS challenge overlay is driven from onCheckoutSuccess (window.wc.blocksCheckoutEvents.checkoutEvents), which the Store API awaits via emitWithAbort before marking checkout complete. Returning a promise from the handler holds the shopper on the checkout page, challenge overlaid on top, until the ACS challenge resolves, instead of navigating to a thank-you page for a payment that has not actually completed. A failed challenge resolves with an ERROR response instead of SUCCESS, which keeps the shopper on checkout with a notice; the order itself was already left pending server-side by begin_challenge(), so there is nothing to undo.

For the two-token mechanics that apply on both checkouts, see Two-token 3D Secure.

Admin operations

Operation Where What happens
Capture The order screen, when Payment Action is Authorize only Captures the reserved funds.
Void The order screen Reverses an uncaptured authorization.
Refund WooCommerce's own refund UI on the order screen Calls process_refund(). Full and partial both supported; the verb is chosen as described above.

Refunds go through WooCommerce's native admin refund control, not a plugin-specific button, so the merchant workflow is the standard one. Debug logging, when enabled, writes gateway activity to WooCommerce, Status, Logs.

Saved cards

Saved cards use WC_Payment_Tokens, WooCommerce's own vault. Unlike PrestaShop and OpenCart, Woo has a token API, so saved cards appear in the shopper's account and at checkout with no custom UI from the plugin.

Saved-card reuse works on the Block Checkout without any extra code. WooCommerce's own saved-payment-token UI renders there regardless of what a payment method declares, because it comes from WC_Payment_Tokens rather than from get_supported_features(), and it submits wc-inovio-payment-token generically as part of the Store API's payment data, which collect_payment_data() already reads unchanged on either checkout type. This was verified live: an existing saved Visa was charged through the Block Checkout with zero new code and zero new tokens created.

When a saved card is selected, the new-card fields are hidden. This matters: WooCommerce auto-selects a stored card, and a saved-card payment needs no tokenization, so anything typed into the new-card fields would be silently dropped and the stored card charged instead. Hiding them prevents a shopper who types a different card from being charged the old one.

Known gaps

  • Saving a new card is classic-checkout-only. The classic checkout renders its own "Save this card for future purchases" checkbox ($this->save_payment_method_checkbox()). The Blocks card-entry component does not render an equivalent, and Inovio_Blocks_Support::get_supported_features() deliberately omits 'tokenization', so WooCommerce Blocks' own built-in save-option checkbox does not appear either. Verified live: entering a new card on the Block Checkout shows no save-card control anywhere on the page. A shopper can charge a new card there, with and without 3DS, but cannot opt to save it for later. Reusing an already-saved card works on both checkouts.

Verified end to end

A 10-spec Playwright suite in e2e/ drives the classic checkout through a real browser, with screenshots and video:

cd e2e && npx playwright test
Spec What it proves
01-sale.spec.js Add to cart, pay with a new card, order confirms.
02-vault.spec.js Save a card, then reuse it on a later order.
03-3ds.spec.js A real Cardinal ACS challenge is presented and completed by the shopper.
04-frictionless-3ds.spec.js With 3DS active and a frictionless PAN, no challenge appears and the order confirms.
05-decline.spec.js A declined card does not create a paid order.
06-refund.spec.js Refunding a Sale order from the WooCommerce admin order screen.
07-invalid-card-input.spec.js Luhn-failing PAN, short PAN, short CVV and a past expiry are all rejected client-side, with no order created.
08-blocks-checkout.spec.js A real order completes through the actual Block Checkout DOM, placed by the same server-side process_payment() the classic specs use.
09-saved-card-ux.spec.js With a saved card selected, the new-card fields are hidden rather than editable-but-ignored.

The suite requires the checkout page to be on the classic [woocommerce_checkout] shortcode; playwright.config.js's global setup switches the page to it and the teardown restores the Block Checkout. Spec 08 needs the opposite and switches the page itself, so it is self-contained regardless of run order.

Block Checkout was also verified live outside the suite, with the checkout page switched to <!-- wp:woocommerce/checkout -->: a new card without 3DS (PO_ID 18193587, TRANS_ID 2001994863, zero browser console errors, genuine .wp-block-woocommerce-checkout DOM confirmed rather than a fallback render), and a new card with a real Cardinal step-up OTP entered through the nested cross-origin ACS iframe (PO_ID 18193577, TRANS_ID 2001994852, ECI 05, with an order note confirming 3-D Secure authentication completed). A full mysqldump after these runs contained zero occurrences of either test PAN.

There is also a CLI script, tests/e2e-order.php, that places a real order through the plugin's own gateway class, performing exactly the two steps the checkout JS does in the browser and handing only the resulting token to process_payment().

Troubleshooting

Symptom Cause Fix
No payment method appears at checkout One of the five required credential fields is empty, so the method hides itself. Fill in API Username, API Password, Site ID, Site Key and Gateway Product ID.
Still no payment method, and credentials are complete The checkout page is neither the classic [woocommerce_checkout] shortcode nor the woocommerce/checkout block, for example a page builder's own checkout. Put the checkout page on one of the two supported checkout types.
Error 121 at checkout, or the card form never tokenizes Site Key missing or wrong. Enter the per-site HMAC Site Key from Inovio support. It is not the API password.
Invalid Data returned on every transaction The statement descriptor contains a space, underscore or forward slash. Remove them, or clear the field.
No "save this card" control on the Block Checkout Known gap. Vaulting a new card is classic-checkout-only. Reusing an already-saved card works on both.
Refund fails with SERVICE 536 The order has not settled, so a partial credit is the wrong verb. Retry the partial refund after settlement. A full refund is routed by the gateway and does not hit this.
A test transaction declines with Insufficient Funds The Inovio test gateway decides from the order total, not the card. Change the order total. See Testing.

Source

Repository: Inoviopay/inovio-gateway-woocommerce (private, available from Inovio).

Plugin directory: inovio-payment-gateway/, installed to wp-content/plugins/inovio-payment-gateway/.