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.
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:
settledis 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.conversionis 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
import { Money } from '@inovio/gateway-sdk';
const ok = Money.of('10.00', 'USD');
Money.of(1.25, 'USD'); // TypeError: pass '1.25', not 1.25
from decimal import Decimal
from inovio_gateway import Money
ok = Money.of("10.00", "USD")
also_ok = Money.of(Decimal("10.00"), "USD")
Money.of(1.25, "USD") # TypeError: pass "1.25", not 1.25
import com.inoviopay.gateway.model.Money;
Money ok = Money.of("10.00", "USD");
Money.of(1.25, "USD"); // throws — the double overload exists to reject floats
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
}
try {
await client.sale({ ...req, idempotency: { xtlOrderId: 'ORDER-555' } });
} catch (e) {
if (e instanceof TimeoutError) {
console.warn(e.recoveryHint);
const actual = await client.status(Refs.xtlOrder('ORDER-555'));
// a blind retry here could double-charge
}
}
from inovio_gateway import InovioTimeoutError, Refs
try:
client.sale(req)
except InovioTimeoutError as e:
print(e.recovery_hint)
actual = client.status(Refs.xtl_order(e.xtl_order_id))
# a blind retry here could double-charge
try {
client.sale(req.idempotency("ORDER-555"));
} catch (GatewayTimeoutException e) {
log.warn(e.recoveryHint());
OrderStatus 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.
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,.terminaland.stopRecurring(service_classification.stop_recurringin 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 ofpositive,partial,negativeorneutral. Derived from the AVS code.partialmeans 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.
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.
- Prepare. Call
prepare()against3dsrequest.cfm. It returns a JWT, the 3DS provider's collection URL, and a DDC reference id. Your page POSTs the JWT (field nameJWT) to that URL in a hidden iframe. - Enrollment leg. A normal
sale()orauthorize()carrying aThreeDSblock with the DDC reference id and your return URL.BrowserDatais required: without it the gateway silently skips 3DS entirely. AnAPPROVEDorDECLINEDresult here means frictionless authentication and you are done.PENDINGmeans a challenge is required. - Challenge. On
PENDING,nextActioncarries a redirect URL and a JWT. Your page POSTs that JWT to that URL in a visible iframe. The ACS then POSTsTRANSACTIONID,RESPONSEandMDback to your return URL. - Completion leg. Pass the challenge outcome to
completeSale()orcompleteAuthorize(), reusing the same request object that ran the enrollment leg. TheRESPONSEvalue 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.
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.
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.