# PHP SDK

The Inovio gateway SDK for PHP 8, with no Composer dependencies, an injectable HTTP client, and the full 3D Secure server legs.

Source: https://developer.inoviopay.com/sdks/php.html  
Markdown: https://developer.inoviopay.com/sdks/php.md
Repository: https://github.com/Inoviopay/inovio-gateway-sdk-php

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.

> **The SDK is alpha**
> 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:

```bash
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:

```json
{
  "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

```php
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:

```php
$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.

```php
$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:

```php
$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](https://developer.inoviopay.com/api/sale.md). |
| `authorize(TransactionRequest $req): TransactionResult` | `CCAUTHORIZE` | Hold funds; capture later. See [Authorize](https://developer.inoviopay.com/api/authorize.md). |
| `capture(OrderRef $order, ?Money $amount = null): TransactionResult` | `CCCAPTURE` | Omit the amount to capture in full. See [Capture](https://developer.inoviopay.com/api/capture.md). |
| `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](https://developer.inoviopay.com/api/reversal.md). |
| `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](https://developer.inoviopay.com/api/credit.md). |
| `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](https://developer.inoviopay.com/api/status.md). |
| `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](https://developer.inoviopay.com/api/tokenization.md). |
| `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](https://developer.inoviopay.com/sdks/concepts.md#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:

```php
$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](https://developer.inoviopay.com/sdks/concepts.md#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.

```php
// 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:

```php
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.
```

> **tokenize() runs on your server**
> 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](https://developer.inoviopay.com/sdks/index.md#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.

```php
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 both `pmtId` and `custId` are 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](https://developer.inoviopay.com/api/3ds-external.md). 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](https://developer.inoviopay.com/sdks/concepts.md#3d-secure) for the flow described language-agnostically,
and [Gateway 3DS](https://developer.inoviopay.com/api/3ds-gateway.md) 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.

```php
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:

```php
$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

```bash
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.

```bash
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:

```bash
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.
