Markdown

How the SDKs think

The object model shared by all four Inovio SDKs, covering the status lifecycle, decimal money, idempotency, outcome tiers, reconciliation, tokenization and 3D Secure.

The four SDKs are projections of one object model. The names change to match each language, but the shapes, the invariants and the surprises are identical. Learn them once and the other three read as translations.

The SDKs are alpha

Version 0.1.0-alpha, not published to a package registry. Install from the public GitHub repositories described on the SDKs overview.

Actions are methods

The gateway protocol is a single form-encoded endpoint discriminated by REQUEST_ACTION. The SDKs never ask you to write one. Each action is a method whose parameters are the ones that action actually consumes.

Method REQUEST_ACTION Purpose
sale CCAUTHCAP Authorize and capture in one step. See Sale.
authorize CCAUTHORIZE Hold funds; capture later. See Authorize.
capture CCCAPTURE Capture an authorization, in full or in part. See Capture.
captureLineItem CCCAPTURE Capture one line item. Needs the parent order, the item, and an amount.
reverse CCREVERSE Void the original authorization. See Reversal.
reverseCapture CCREVERSECAP Void a capture rather than the original auth.
refund CCCREDIT Refund against an existing order, in full or in part. See Credit.
forceCredit CCCREDIT + FORCE_CREDIT Credit with no referenced original. Needs MID provisioning.
status CCSTATUS Order-level net position and unknown-state recovery. See Status.
updateOrder CCTRANSUPDATE Attach receipts to an existing order.
tokenize token service Exchange a PAN for a single-use token. See Tokenization.
testAuth TESTAUTH Verify credentials without transacting.
testAvailability TESTGW Verify gateway availability. Safe to poll.

Method names are camelCase in PHP, Node and Java, and snake_case in Python: capture_line_item, reverse_capture, force_credit, update_order, test_auth, test_availability.

The follow-up methods take typed references, never bare strings. capture() accepts an OrderRef, captureLineItem() accepts an OrderRef plus a LineItemRef, and status() accepts either an OrderRef or an XtlOrderId. Every result exposes the references it produced, so chaining authorize into capture is type-safe and a customer id cannot be passed where an order id belongs.

The five-state result

Every transaction method returns a result whose status is one of five values, taken from Appendix B of the v4.14 specification:

Status Meaning
APPROVED The transaction succeeded.
DECLINED The transaction was refused. You got an answer.
PENDING A 3DS challenge is outstanding, or an async rail is awaiting settlement.
RUNNING Gateway processing is incomplete.
FAILED An EU direct-debit payment did not complete.

Two consequences shape everything else.

A decline is a return value, not an exception. sale() returns normally with status set to DECLINED, carrying the full outcome tiers, AVS and CVV detail. Exceptions are reserved for cases where you never got a payment answer at all: transport failure, authentication, validation and configuration. Wrap your calls in try/catch for the exceptional cases, and branch on status for the payment answer.

There are no approved or declined booleans. They were deliberately left out. A boolean invites if (approved) { ... } else { ... }, which silently treats PENDING as a failure, and PENDING is a real, non-failed state. The enum forces you to decide what your integration does about it. settling survives as a convenience because it is a genuine grouping (PENDING or RUNNING), not a one-to-one alias for a status value.

Two related fields catch people out:

  • settled is almost always false at response time. It is written 0 at authorization and flipped later by batch settlement, except on settle-on-auth processors. It is not a failure signal.
  • conversion is populated only on real FX. On a domestic transaction the wire's settled-amount fields are just the auth amount echoed back, so a block that was always present would tell you nothing. The SDKs gate it on a non-null exchange rate and name it for what it reports.

Money is a decimal

Amounts never pass through a binary float. Each SDK uses its language's decimal representation and rejects float input at the boundary rather than silently corrupting an amount, because 0.1 plus 0.2 is not 0.3 in binary floating point and the wire format is a decimal string like "1.25".

Language Internal type Accepts Rejects
PHP bcmath decimal string string, int float
Node decimal string string number
Python decimal.Decimal Decimal, str, int float
Java BigDecimal String, BigDecimal double

The rounding decision is yours and must be explicit. Java goes furthest: it declares a double overload whose only job is to reject floating point with a clear message rather than let the call bind to something implicit.

Currency is an ISO-4217 alpha-3 code, validated at construction. Equality compares numerically, so "1.5" equals "1.50".

use Inovio\Gateway\Model\Money;

$ok = Money::of('10.00', 'USD');
Money::of(1.25, 'USD');   // InvalidArgumentException: pass "1.25", not 1.25

Idempotency and timeouts

A timeout does not mean the transaction failed. It means the state is unknown: the gateway may have approved the charge and lost the response. Retrying blindly can charge the customer twice. Two mechanisms work together to make that safe.

Idempotency. Set your own order id on the request and the SDK sends it as XTL_ORDER_ID with the mode defaulted to RETURN_ORIGINAL. A repeat of the same request then returns the original result instead of creating a second charge. The mode is settable to OFF or DECLINE_DUP if you want the gateway to refuse duplicates outright rather than replay the first answer.

The timeout exception carries the key. When the transport times out, the SDK raises an exception that carries your order id and a recovery hint. That lets you resolve what actually happened rather than guess. If no order id was set, the exception says so, because without a key there is nothing to look the transaction up by.

Note the exception name differs by language, and in two of the four it deliberately avoids shadowing a builtin:

Language Exception Key accessor
PHP GatewayTimeoutException xtlOrderId()
Node TimeoutError xtlOrderId
Python InovioTimeoutError xtl_order_id
Java GatewayTimeoutException xtlOrderId()

Python's is not called TimeoutError because callers routinely catch the builtin of that name, and shadowing it would hide exactly the unknown-state case that needs recovery. Java's is not called TimeoutException so it is never confused with java.util.concurrent.TimeoutException.

The default timeout is 120 seconds in all four SDKs, matching the gateway's own window.

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()));
    // a blind retry here could double-charge
}

Outcome tiers

Every response carries four independent response-and-advice tiers, from outermost to innermost. They answer different questions, and collapsing them loses information.

Tier Wire fields What it reports
API API_RESPONSE / API_ADVICE, plus REF_FIELD Gateway request validation: credentials, field format, configuration. Fires before the processor is reached. See API codes.
Service SERVICE_RESPONSE / SERVICE_ADVICE Gateway transaction outcome and the decline taxonomy. See Service codes.
Processor PROCESSOR_RESPONSE / PROCESSOR_ADVICE Acquirer or bank level.
Industry INDUSTRY_RESPONSE / INDUSTRY_ADVICE Issuing-bank level.

Alongside those sit the risk results: avs and cvv, from Appendices E and F. See AVS codes and CVV codes.

The API tier is the one that becomes an exception. The SDKs map known API error codes onto the exception hierarchy: authentication, validation (carrying the offending REF_FIELD), configuration and rate limit. The service tier and below always come back as a result.

The gateway sends codes. The SDK adds labels.

A result carries three layers. outcome holds the response codes exactly as the gateway returned them, one entry per tier. raw holds every wire field untouched. Between them sit a few labels the SDK derives by looking the codes up in its own tables. You will branch real business logic on those labels, so know which fields they are:

  • serviceClassification.retryable, .terminal and .stopRecurring (service_classification.stop_recurring in Python). Derived from the service response code. Retryable means a later attempt might succeed, for instance insufficient funds. Terminal means it never will, for instance a closed account. Stop recurring means the issuer is telling you to end the subscription. Dunning logic branches on these.
  • avs.classification, one of positive, partial, negative or neutral. Derived from the AVS code. partial means street or postal code matched but not both.

Reading a label means trusting the SDK's reading of the code tables. Reading outcome or raw means applying your own. Whether a partial AVS result is acceptable is your risk decision: the SDK labels it and deliberately does not approve or reject on your behalf.

OrderStatus is the reconciliation primitive

status() is not just the timeout recovery path. It is the only correct source of net figures for any order with more than one leg.

In the gateway, a partial capture, a refund and a void are not modifications of the original transaction. They are separate transaction rows sharing a PO_ID, each with its own transaction id. Net position is therefore an order-level question, and a single TransactionResult cannot answer "what did this order actually settle for". OrderStatus can:

Field Derivation
transactions Every leg against the order, in order: auth, captures, refunds, voids.
authorized The original authorization.
captured Sum of the capture legs.
refunded Sum of the refund and void legs.
net captured minus refunded.
outstanding authorized minus captured, the uncaptured balance.
settled True once every settle-eligible leg has settled.

The arithmetic mirrors the way the gateway's own settlement batch derives these figures, summing siblings keyed on PO_ID. The SDK does that summing so you do not have to.

One protocol quirk worth knowing, because it is not described in the v4.14 response-fields section: CCSTATUS does not answer with flat fields like every other action. It returns a tabular payload with COLUMNS and DATA arrays, one DATA row per leg. All four SDKs parse that shape internally and hand you typed legs. This was verified against the live gateway.

Tokenization

tokenize() exchanges a PAN for a single-use TOKEN_GUID that replaces PMT_NUMB on a later sale or authorize. It hits a different endpoint (token_service.cfm) with different authentication: HMAC headers rather than username and password.

You need a site key, a per-site HMAC secret issued by Inovio support. It is not your gateway password. Without it the token service answers error 121.

Two things the SDKs handle that the specification will mislead you on.

The signed message excludes the PAN. The v4.14 PDF's section 4.8.1.2 note says the HMAC covers card_pan, and its worked example agrees. The gateway does not. Verified against the live token service, the gateway validates:

hmac_sha256(timestamp || unique_id || site_id, site_key)

Signing with the card number included fails with error 121. The SDKs sign the way the gateway actually behaves, not the way the document describes.

A token replaces the PAN only. The transaction still needs the expiry, and the CVV where the processor asks for it. tokenize() carries both forward onto the returned token for you. Sending a bare TOKEN_GUID yields API 110 Required field on REF_FIELD=pmt_expiry.

BIN metadata on the result (brand, bank, country and friends) is best-effort. The service returns those keys empty when the BIN is not in its lookup table, and the SDKs normalise blanks to null so you can test for presence rather than for an empty string.

All four SDKs also export the signing helper on its own, so a merchant-hosted signature endpoint can sign a request that the browser then posts directly to the token service. The helper is Tokenize::signRequest() in PHP, signTokenRequest() in Node, sign_token_request() in Python and Tokenize.signRequest() in Java.

tokenize() is a server-side call

The card number passes through your server, which puts your server inside your cardholder data flow. The browser Hosted Fields client that would keep the PAN in the cardholder's browser is not yet available. See Where the card number goes.

3D Secure

Gateway-managed 3DS is a multi-leg flow. 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.

  1. Prepare. Call prepare() against 3dsrequest.cfm. It returns a JWT, the 3DS provider's collection URL, and a DDC reference id. Your page POSTs the JWT (field name JWT) to that URL in a hidden iframe.
  2. Enrollment leg. A normal sale() or authorize() carrying a ThreeDS block with the DDC reference id and your return URL. BrowserData is required: without it the gateway silently skips 3DS entirely. An APPROVED or DECLINED result here means frictionless authentication and you are done. PENDING means a challenge is required.
  3. Challenge. On PENDING, nextAction carries a redirect URL and a JWT. Your page POSTs that JWT to that URL in a visible iframe. The ACS then POSTs TRANSACTIONID, RESPONSE and MD back to your return URL.
  4. Completion leg. Pass the challenge outcome to completeSale() or completeAuthorize(), reusing the same request object that ran the enrollment leg. The RESPONSE value may legitimately be empty; that is not an error, and it must still be passed through as an empty string.

On the completed result, threeDS.eci values 05 and 06 mean fully authenticated, which is where liability shift applies.

Only the PHP SDK implements the 3DS server legs

prepare(), completeSale() and completeAuthorize() exist only in Inovio\Gateway\ThreeDSecureClient. The Node, Python and Java SDKs carry the BrowserData block on a request and surface a threeDSChallenge nextAction when a result is PENDING, but they do not yet expose a threeDSecure() sub-client. In those three languages, drive the prepare and completion legs against the 3DS endpoints directly.

Here is the PHP flow end to end:

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() identifies the card three ways. Use ThreeDSPrepare::card() when you hold the PAN, ::savedCard() for a vaulted card (the gateway looks the BIN up, and requires both pmtId and custId), and ::bin() when the browser tokenized the card but you captured the BIN client-side.

Partners running their own 3DS provider skip all of the above. Attach a ThreeDSResult carrying your CAVV, ECI and transaction id to a normal one-leg sale(). See External 3DS. This external path is also PHP-only in the current alpha.

Reverse versus credit

A reversal voids a transaction that has not settled. Once a transaction has settled, it cannot be reversed and the money has to come back as a credit instead. Rather than make you detect that yourself, the gateway will do the re-routing for you.

Sending CREDIT_ON_FAIL=1 alongside CCREVERSE or CCREVERSECAP tells the gateway: if this transaction is already settled and cannot be reversed, re-route the request to CCCREDIT. The response then comes back carrying REQUEST_ACTION=CCCREDIT instead of the reversal action you sent, which is how you know which path it took. This behaviour is verified against the gateway.

Only the PHP SDK exposes this flag

reverse() and reverseCapture() take bool $creditOnFail = false in PHP only. The Node, Python and Java signatures take just the order reference and never send CREDIT_ON_FAIL. Until they do, call the reversal endpoint directly with the flag set. Do not substitute a status() settlement check followed by a client-side choice between reverse() and refund(): the settled flag flips in batch and the gateway's own routing is the only reliable answer.

// PHP: let the gateway decide between void and credit.
$r = $client->reverse($order, creditOnFail: true);

if ($r->action === 'CCCREDIT') {
    // it was already settled; the gateway credited instead of voiding
}

Do not write your own settled-versus-unsettled pre-check and fall back between the two calls. The gateway owns that routing decision and has information you do not.

What the SDK hides

Wire quirks are normalised once, internally, and never reach you:

Wire SDK
REQUEST_ACTION=CCAUTHCAP client.sale()
REQUEST_INITATOR (misspelled in the protocol) recurring.initiator
XTL_ORDER_ID and XTL_PO_ID (the same thing) xtlOrderRef
PMT_L4 and PMT_LAST4 (the same thing) card.last4
PMT_NUMB meaning PAN, bank account or IBAN PaymentMethod variants
LI_VALUE_1, LI_COUNT_1, and the rest of the indexed set a list of LineItem
Case-inconsistent response keys an upper-cased map
CCSTATUS returning COLUMNS/DATA instead of flat fields typed OrderStatus legs

Every result also carries raw, the complete unmodified field map, as an escape hatch for anything the model does not surface.

Errors

All four SDKs share one hierarchy. Only the naming suffix differs: PHP and Java use Exception, Node and Python use Error.

Exception Raised for
InovioException / InovioError Base type for everything below.
AuthenticationException / AuthenticationError Bad credentials, inactive account, bad site or service. API tier 100 to 106.
ValidationException / ValidationError Missing or invalid input, caught locally before send or reported by the gateway. Carries the offending refField.
ConfigurationException / ConfigurationError Currency, product or merchant account not configured.
TransportException / TransportError Network failure.
GatewayTimeoutException / TimeoutError / InovioTimeoutError The timeout case. Subclass of the transport error. Carries the idempotency key.
RateLimitException / RateLimitError Throttled.

In Java every one of these is unchecked, so nothing forces a throws clause onto your call sites.

Remember what is not in this list: a decline. That is a TransactionResult with status set to DECLINED, and the decline taxonomy lives on its service tier.

One case that surprises people: forceCredit() fails with API 104 "Invalid service action" unless the merchant account has FORCE_CREDIT enabled. That arrives as an authentication-tier exception, not a decline, because the gateway rejected the request before it ever reached a processor.