WooCommerce
The Inovio Payment Gateway plugin for WooCommerce, with tokenized direct-post card entry on both the classic and Block checkouts.
Tokenized direct-post card checkout for WooCommerce, on the Inovio gateway. The card number never reaches the WordPress server.
Overview and status
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
Plugins -> Installed Plugins -> Inovio Payment Gateway -> Activate
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 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, andInovio_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/.