PHP SDK
The Inovio gateway SDK for PHP 8, with no Composer dependencies, an injectable HTTP client, and the full 3D Secure server legs.
The Inovio payment gateway for PHP 8. Card transactions covering authorize, capture, refund and tokenize, designed to drop into a WooCommerce, Magento or custom cart. Bring your own HTTP client; there are no Composer dependencies.
This is the only one of the four SDKs that ships the complete 3D Secure server
legs and the creditOnFail reversal flag.
Version 0.1.0-alpha and not published to Packagist. Install from the public GitHub repository as shown below, and pin a commit until a tagged release lands.
Status and install
Add the repository to your composer.json, then require the package from the
main branch:
composer config repositories.inovio vcs https://github.com/Inoviopay/inovio-gateway-sdk-php
composer require inovio/gateway-sdk:dev-main
Or write it into composer.json directly:
{
"repositories": [
{ "type": "vcs", "url": "https://github.com/Inoviopay/inovio-gateway-sdk-php" }
],
"require": {
"inovio/gateway-sdk": "dev-main"
}
}
The package autoloads via a classmap over src/, so it also works if you clone
the repository and require the classmap yourself, without Composer at all.
Requirements
PHP 8.0 or newer, with ext-json, ext-bcmath and ext-curl. No Composer
dependencies.
bcmath is required, not optional. Money does its decimal arithmetic through
it so that amounts never touch a binary float.
Quick start
use Inovio\Gateway\{Credentials, InovioClient};
use Inovio\Gateway\Model\{LineItem, Money, PaymentMethods};
use Inovio\Gateway\Request\TransactionRequest;
$client = new InovioClient(new Credentials($user, $password, '123'), 'SANDBOX');
$req = (new TransactionRequest(
PaymentMethods::card('4111111111111111', '122030', '123'),
[new LineItem('SKU-1', 1, Money::of('10.00', 'USD'))]
))->withIdempotency('ORDER-555'); // retry-safe by default
$result = $client->sale($req);
match ($result->status) {
'APPROVED' => /* fulfil */,
'DECLINED' => /* $result->outcome->service, $result->serviceClassification */,
'PENDING' => /* $result->nextAction — 3DS challenge, redirect, voucher */,
default => /* RUNNING | FAILED */,
};
The constructor takes credentials and an environment, then a long tail of optional arguments. Use named arguments for anything past the environment:
$client = new InovioClient(
credentials: new Credentials($user, $password, '123'),
environment: 'PRODUCTION',
httpClient: new MyPsr18Adapter(),
timeoutMs: 30000,
siteKey: $siteKey, // required only for tokenize()
);
PHP-specific notes
Injectable HTTP client. Host platforms usually want their own transport:
WordPress wp_remote_post, Magento's PSR-18 client, or an instrumented client
of your own. Implement Inovio\Gateway\Transport\HttpClient and pass it in.
The SDK never assumes it owns the socket.
$client = new InovioClient($creds, 'PRODUCTION', null, new MyPsr18Adapter());
Throw Inovio\Gateway\Transport\TimeoutSignal from your adapter on timeout so
the SDK can convert it into a GatewayTimeoutException with the idempotency key
attached. Without that signal the SDK cannot tell a timeout apart from any other
transport failure, and you lose the recovery path.
bcmath money. Money holds the amount as a string and does arithmetic
through bcmath at scale 8. Money::of() accepts a decimal string or an int, and
throws InvalidArgumentException on a float. Money::of(1.25, 'USD') throws;
pass '1.25'.
Named arguments over builders. The model objects use public promoted properties and named arguments rather than fluent builders, so a request is assembled by assignment:
$req->customer = new Customer();
$req->customer->email = 'ada@example.invalid';
$req->billingAddress = new Address();
$req->billingAddress->country = 'US';
Statuses are strings. $result->status is a plain string, one of
APPROVED, DECLINED, PENDING, RUNNING, FAILED, so it works directly in
a match expression.
Operations
Every public method on InovioClient, with the REQUEST_ACTION it maps to:
| Method | Action | Notes |
|---|---|---|
sale(TransactionRequest $req): TransactionResult |
CCAUTHCAP |
Authorize and capture in one step. See Sale. |
authorize(TransactionRequest $req): TransactionResult |
CCAUTHORIZE |
Hold funds; capture later. See Authorize. |
capture(OrderRef $order, ?Money $amount = null): TransactionResult |
CCCAPTURE |
Omit the amount to capture in full. See Capture. |
captureLineItem(OrderRef $order, LineItemRef $item, Money $amount): TransactionResult |
CCCAPTURE |
All three arguments are required. |
reverse(OrderRef $order, bool $creditOnFail = false): TransactionResult |
CCREVERSE |
Void the original authorization. See Reversal. |
reverseCapture(OrderRef $order, bool $creditOnFail = false): TransactionResult |
CCREVERSECAP |
Void a capture rather than the original auth. |
refund(OrderRef $order, ?Money $amount = null): TransactionResult |
CCCREDIT |
Omit the amount to refund in full. See Credit. |
forceCredit(TransactionRequest $req): TransactionResult |
CCCREDIT + FORCE_CREDIT |
Credit with no referenced original. Needs MID provisioning. |
status(OrderRef\|XtlOrderId $ref): OrderStatus |
CCSTATUS |
Order-level net position. See Status. |
updateOrder(OrderRef $order, OrderUpdate $update): TransactionResult |
CCTRANSUPDATE |
Attach a receipt and UDF metadata. |
tokenize(Card $card, ?string $uniqueId = null): TokenizeResult |
token service | Needs siteKey. See Tokenization. |
threeDSecure(): ThreeDSecureClient |
3DS legs | Sub-client, described below. |
testAuth(): HealthResult |
TESTAUTH |
Verify credentials without transacting. |
testAvailability(): HealthResult |
TESTGW |
Verify gateway availability. Safe to poll. |
captureLineItem() requires the parent order as well as the line item. The
gateway rejects REQUEST_REF_PO_LI_ID on its own with API 113 "Invalid Data",
and LineItemRef does not carry its order, so both must be passed. This was
verified against the live gateway.
reverse() and reverseCapture() accept $creditOnFail. With it set the SDK
sends CREDIT_ON_FAIL=1, and if the transaction is already settled and cannot
be reversed, the gateway itself re-routes the request to CCCREDIT. The response
then carries REQUEST_ACTION=CCCREDIT instead of the action you sent. This
parameter exists only in the PHP SDK; see
Reverse versus credit.
Handling results
$result->status carries the answer. A decline is a return value, not an
exception, so branch on the status rather than wrapping in try/catch for the
payment outcome:
$r = $client->sale($req);
show('status', $r->status);
show('order', $r->orderRef?->poId() ?? '-');
show('amount', $r->amount ? $r->amount->toWire() . ' ' . $r->amount->currency() : '-');
show('card', sprintf('%s ****%s', $r->card->brand ?? '?', $r->card->last4 ?? '?'));
switch ($r->status) {
case 'APPROVED':
show('next', 'fulfil the order');
break;
case 'DECLINED':
// The service tier carries the decline taxonomy your dunning logic needs.
show('next', $r->serviceClassification?->retryable ? 'retry later' : 'do not retry');
break;
case 'PENDING':
show('next', 'complete ' . ($r->nextAction->kind ?? '?'));
break;
default:
show('next', 'inspect $r->outcome');
}
Reference keys sit flat on the result, not inside a nested bag, because they are
the fields you reach for most: $client->capture($r->orderRef, $amount). The
available refs are orderRef, xtlOrderRef, transactionId, requestId,
batchId, customerRef, savedCardRef, membershipRef and lineItemRefs.
The gateway sends codes; the SDK adds labels. $r->serviceClassification->retryable,
->terminal and ->stopRecurring, plus $r->avs->classification, are labels
the SDK derives from the response codes, not values the gateway sent. The codes
themselves are on $r->outcome and the untouched wire fields on $r->raw. See
Outcome tiers.
Exceptions extend Inovio\Gateway\Errors\InovioException:
AuthenticationException, ValidationException (carrying the offending
refField), ConfigurationException, TransportException,
GatewayTimeoutException (a subclass of TransportException) and
RateLimitException.
Tokenization
tokenize() exchanges a PAN for a single-use TOKEN_GUID that replaces
PMT_NUMB on a later sale or authorize. It hits token_service.cfm with HMAC
header authentication rather than username and password, so it needs the
per-site siteKey on the client. Without it the call throws a
ValidationException before any network traffic, and the service itself would
answer error 121.
// Tokenize on the site that holds the HMAC key.
$t = $tokenClient->tokenize(PaymentMethods::card($pan, $expiry, $cvv));
echo $t->token->guid();
echo $t->tokenReqId; // quote this to support
// BIN metadata is best-effort — null when the BIN is not in the lookup table.
echo $t->card->brand; // 'Visa'
echo $t->card->bank;
// The token replaces the PAN only: expiry and CVV still travel with it, which
// tokenize() carries forward for you.
$req = new TransactionRequest($t->token, [new LineItem('SKU-1', 1, Money::of('10.00', 'USD'))]);
$sale = $client->sale($req->withIdempotency('ORDER-556'));
The signed message excludes the PAN, contrary to what the v4.14 PDF says.
The gateway validates hmac_sha256(timestamp || unique_id || site_id, site_key),
verified against the live token service. The SDK signs the way the gateway
behaves.
For a merchant-hosted signature endpoint that lets a browser post the PAN directly, use the signing helpers on their own:
use Inovio\Gateway\Tokenize;
$timestamp = Tokenize::timestamp(); // YYYYMMDDHHMMSS UTC
$signature = Tokenize::signRequest($siteKey, $timestamp, $uniqueId, $siteId);
// Return {$signature, $timestamp, $siteId} to the browser. Never the site key.
The card number passes through your infrastructure. The browser Hosted Fields client that would keep it in the cardholder's browser is not yet available. See Where the card number goes.
3D Secure
The SDK owns every server leg; your page owns two iframes, a hidden one for device-data collection and a visible one for the challenge.
use Inovio\Gateway\ThreeDSPrepare;
use Inovio\Gateway\Model\{BrowserData, ThreeDS, ThreeDSChallengeResult};
// 1. Start the session — returns what the DDC iframe needs.
$ddc = $client->threeDSecure()->prepare(
ThreeDSPrepare::card($card, 'USD', 'US')
);
// Browser: POST $ddc->jwt (field name JWT) to $ddc->ddcUrl in a hidden iframe.
// 2. Enrollment leg — a normal sale/authorize carrying the ThreeDS block.
// BrowserData is REQUIRED: without it the gateway silently skips 3DS.
$req->browser = new BrowserData($lang, $userAgent, $acceptHeader,
javaEnabled: false, colorDepth: 24, screenHeight: 1080, screenWidth: 1920,
timeZoneOffsetMinutes: 480);
$req->threeDS = new ThreeDS($ddc->ddcReferenceId, 'https://you.example/3ds-return');
$r = $client->sale($req);
match ($r->status) {
'APPROVED', 'DECLINED' => /* frictionless — done, check $r->threeDS->eci */,
'PENDING' => /* challenge: POST $r->nextAction->jwt to
$r->nextAction->redirectUrl in a visible iframe */,
};
// 3. The ACS POSTs TRANSACTIONID / RESPONSE / MD to your return URL.
// RESPONSE may be EMPTY — that is not an error; pass it through as ''.
$final = $client->threeDSecure()->completeSale(
$req,
new ThreeDSChallengeResult($_POST['TransactionId'], $_POST['Response'] ?? '')
);
// $final->threeDS?->eci — 05/06 means fully authenticated (liability shift).
prepare() sends no REQUEST_ACTION; the 3DS request service derives the
action from the username and password auth path. That is verified against the
deployed endpoint.
Three ways to identify the card on prepare():
ThreeDSPrepare::card($card, $currency, $country)when you hold the PAN.ThreeDSPrepare::savedCard($saved, $currency, $country)for a vaulted card. The gateway looks the BIN up, and bothpmtIdandcustIdare required.ThreeDSPrepare::bin($bin, $currency, $country)when the browser tokenized the card and you captured at least the first six digits client-side.
Use completeAuthorize() instead of completeSale() when the enrollment leg
was an authorize(). Both reuse the same request object that ran the
enrollment leg, swapping its ThreeDS block for the challenge result.
Partners running their own 3DS provider skip all of this and attach
$req->threeDSResult = new ThreeDSResult($cavv, $eci, $transId) to a normal
one-leg sale(). See External 3DS. Setting more than
one of threeDS, threeDSChallenge and threeDSResult on a request is a
validation error, because they are three distinct legs.
See 3D Secure for the flow described language-agnostically, and Gateway 3DS for the endpoint contract.
Timeouts and reconciliation
A timeout means the state is unknown, not failed. The gateway may have approved the charge and lost the response.
use Inovio\Gateway\Errors\GatewayTimeoutException;
use Inovio\Gateway\Refs\Refs;
try {
$client->sale($req->withIdempotency('ORDER-555'));
} catch (GatewayTimeoutException $e) {
error_log($e->recoveryHint());
$actual = $client->status(Refs::xtlOrder($e->xtlOrderId()));
// Do NOT retry blindly — that risks a double charge.
}
withIdempotency() defaults the mode to RETURN_ORIGINAL, so a retry of the
same request returns the original result rather than charging twice.
status() is also the reconciliation primitive. Partial captures, refunds and
voids are separate transactions sharing one order, so net position is an
order-level question:
$s = $client->status($order->orderRef);
echo count($s->transactions); // every leg: auth, captures, refunds, voids
echo $s->authorized?->toWire();
echo $s->captured?->toWire();
echo $s->refunded?->toWire();
echo $s->net?->toWire(); // captured - refunded
echo $s->outstanding?->toWire(); // authorized - captured
You can also look an order up by your own id with Refs::xtlOrder('ORDER-555').
Running the conformance tests
php tests/conformance.php # 67 assertions, no Composer needed
python3 scripts/generate_enums.py # regenerate enums from spec/spec-enums.json
The suite is plain PHP rather than PHPUnit so it runs on a bare interpreter. It
replays the shared cross-language fixtures in spec/conformance-fixtures.json,
the same corpus the other three SDKs run, which is what keeps the four honest
about producing identically shaped results.
The enums in src/Enums/Generated.php are generated from
spec/spec-enums.json, extracted from the v4.14 specification appendices. Do
not hand-edit them; regenerate.
Examples in the repo
Fourteen runnable files in examples/, one per operation. They are real,
executed code rather than markdown snippets, so they cannot silently drift from
the API.
php examples/run_all.php # all 14, against a mock transport
php examples/03_sale.php # just one
| File | Operation |
|---|---|
01_test_availability.php |
testAvailability(), the TESTGW health check |
02_test_auth.php |
testAuth(), credential verification with no transaction |
03_sale.php |
sale(), authorize and capture in one step |
04_authorize.php |
authorize(), holding funds and keeping the order ref |
05_capture.php |
capture(), full or partial |
06_capture_line_item.php |
captureLineItem(), which needs order, item and amount |
07_reverse.php |
reverse(), voiding an authorization |
08_reverse_capture.php |
reverseCapture(), voiding a capture pre-settlement |
09_refund.php |
refund(), returning captured funds |
10_force_credit.php |
forceCredit(), which needs MID provisioning |
11_status.php |
status(), reconciliation and net position |
12_update_order.php |
updateOrder(), attaching receipts for compliance |
13_tokenize.php |
tokenize(), PAN to single-use token |
14_timeout_recovery.php |
The pattern that prevents double charges |
By default they run against a mock transport: no credentials, no network, no
money moves, safe in CI. Set INOVIO_LIVE=1 plus credentials to run the same
code against a real gateway:
INOVIO_LIVE=1 \
INOVIO_USER=... INOVIO_PASS=... INOVIO_SITE_ID=... INOVIO_MERCH_ACCT_ID=... \
INOVIO_SITE_KEY=... # token service HMAC key
INOVIO_TOKEN_SITE_ID=... # only if tokenizing on a different site
php examples/run_all.php
Live mode creates real transactions, so point it at a test environment. Running live is worth doing before you trust an integration: mocks only replay responses you already believed in, so they cannot catch request-side errors.
Two examples need provisioning that a fresh account will not have.
forceCredit fails with API 104 "Invalid service action" unless the MID has
FORCE_CREDIT enabled, which is an authentication-tier error rather than a
decline. tokenize needs the per-site HMAC key from Inovio support, which may
live on a different site than your gateway credentials.