Java SDK
The Inovio gateway SDK for Java 11, with zero runtime dependencies and a BigDecimal money type.
The Inovio payment gateway for Java 11 and newer. Card transactions covering authorize, capture, refund and tokenize, with a typed API and no runtime dependencies.
Version 0.1.0-alpha and not published to Maven Central. Build it from the public GitHub repository as shown below, and pin a commit until a tagged release lands.
Status and install
Build and install locally. This is the path that works today, because the repository has no published release tag for a build service to resolve:
git clone https://github.com/Inoviopay/inovio-gateway-sdk-java.git
cd inovio-gateway-sdk-java
mvn install
That puts com.inoviopay:inovio-gateway-sdk:0.1.0-alpha.0 in your local
repository. Then depend on it:
<dependency>
<groupId>com.inoviopay</groupId>
<artifactId>inovio-gateway-sdk</artifactId>
<version>0.1.0-alpha.0</version>
</dependency>
Or in Gradle:
repositories { mavenLocal(); mavenCentral() }
dependencies { implementation 'com.inoviopay:inovio-gateway-sdk:0.1.0-alpha.0' }
JitPack, which builds Maven artifacts on demand straight from a public GitHub repository, is the way to consume it without a local install once the repository carries a release tag or you are willing to resolve a commit. Add the repository and depend on the coordinate JitPack derives from the GitHub path:
<repositories>
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</repository>
</repositories>
<dependency>
<groupId>com.github.Inoviopay</groupId>
<artifactId>inovio-gateway-sdk-java</artifactId>
<version><!-- a commit sha, or a tag once one is published --></version>
</dependency>
Note that JitPack's coordinate is com.github.Inoviopay:inovio-gateway-sdk-java,
derived from the repository path, not the com.inoviopay:inovio-gateway-sdk
coordinate in the project's own pom.xml. Only the import statements are
unaffected; the package names come from the source either way.
Requirements
Java 11 or newer. Zero runtime dependencies: HTTP goes through
java.net.http, and the flat key-value response is read by a small inline JSON
reader rather than pulling in Jackson for a response shape that does not need
it. JUnit is a test-scope dependency only.
Quick start
InovioClient client = new InovioClient(
new InovioClient.Credentials(user, password, "123"));
TransactionRequest req = new TransactionRequest(
PaymentMethods.card("4111111111111111", "122030", "123"),
new LineItem("SKU-1", 1, Money.of("10.00", "USD")))
.idempotency("ORDER-555"); // retry-safe by default
TransactionResult r = client.sale(req);
switch (r.status()) {
case APPROVED: /* fulfil */ break;
case DECLINED: /* r.outcome().service(), r.serviceClassification() */ break;
case PENDING: /* r.nextAction() — 3DS challenge, redirect, voucher */ break;
case RUNNING:
case FAILED: break;
}
Configuration goes through a mutable Options object rather than a long
constructor:
InovioClient.Options o = new InovioClient.Options();
o.environment = Transport.Environment.PRODUCTION;
o.endpoint = "http://localhost:8080/payment/pmt_service.cfm"; // overrides environment
o.timeoutMs = 30_000;
o.httpClient = myInstrumentedClient;
o.siteKey = System.getenv("INOVIO_SITE_KEY"); // required only for tokenize()
InovioClient client = new InovioClient(creds, o);
Java 11 and the sealed hierarchy
This SDK targets Java 11 for enterprise reach rather than newer-JDK ergonomics. The cost is real and worth knowing:
| Type | Java 17+ would be | What this SDK does |
|---|---|---|
PaymentMethod sealed |
sealed interface ... permits |
interface plus final implementations with package-private constructors |
Money, refs, value types |
record |
hand-written final classes |
| Exhaustive status handling | switch expressions | switch plus an explicit default |
PaymentMethod is therefore sealed by convention. Every implementation is
final and its constructor is package-private, so the only way to build one is
PaymentMethods.card(...), .token(...) or .savedCard(...), and outside code
cannot add a variant. What is lost is compiler-enforced exhaustiveness: you
get no error for an unhandled variant in a switch, so write the default
branch yourself.
Java-specific notes
Amounts are BigDecimal. Money.of has three overloads: String,
BigDecimal, and a double overload that exists purely to reject floating
point with a clear message. Money.of(1.25, "USD") throws; pass "1.25" or a
BigDecimal. Money.equals uses compareTo, so "1.5" equals "1.50".
All exceptions are unchecked. InovioException extends RuntimeException,
so nothing forces a throws clause onto your call sites. Catch deliberately.
The timeout exception is GatewayTimeoutException, not
TimeoutException, so it is never confused with
java.util.concurrent.TimeoutException. It subclasses TransportException and
carries xtlOrderId() and recoveryHint().
Card.toString() prints only the last four digits, so a card object landing
in a log line does not leak a PAN.
Overloads stand in for optional arguments. capture(), refund(),
tokenize() and status() each have a shorter overload. status() in
particular is overloaded on Refs.OrderRef and Refs.XtlOrderId rather than
taking a union type.
Operations
Every public method on InovioClient, with the REQUEST_ACTION it maps to:
| Method | Action | Notes |
|---|---|---|
TransactionResult sale(TransactionRequest req) |
CCAUTHCAP |
Authorize and capture in one step. See Sale. |
TransactionResult authorize(TransactionRequest req) |
CCAUTHORIZE |
Hold funds; capture later. See Authorize. |
TransactionResult capture(Refs.OrderRef order, Money amount) |
CCCAPTURE |
Partial capture. See Capture. |
TransactionResult capture(Refs.OrderRef order) |
CCCAPTURE |
Capture in full. |
TransactionResult captureLineItem(Refs.OrderRef order, Refs.LineItemRef item, Money amount) |
CCCAPTURE |
All three arguments are required. |
TransactionResult reverse(Refs.OrderRef order) |
CCREVERSE |
Void the original authorization. See Reversal. |
TransactionResult reverseCapture(Refs.OrderRef order) |
CCREVERSECAP |
Void a capture rather than the original auth. |
TransactionResult refund(Refs.OrderRef order, Money amount) |
CCCREDIT |
Partial refund. See Credit. |
TransactionResult refund(Refs.OrderRef order) |
CCCREDIT |
Refund in full. |
TransactionResult forceCredit(TransactionRequest req) |
CCCREDIT + FORCE_CREDIT |
Credit with no referenced original. Needs MID provisioning. |
OrderStatus status(Refs.OrderRef order) |
CCSTATUS |
Order-level net position. See Status. |
OrderStatus status(Refs.XtlOrderId xtlOrder) |
CCSTATUS |
The same, keyed by your own order id. |
TransactionResult updateOrder(Refs.OrderRef order, OrderUpdate update) |
CCTRANSUPDATE |
Attach a receipt and UDF metadata. |
Tokenize.Result tokenize(Card card) |
token service | Needs Options.siteKey. See Tokenization. |
Tokenize.Result tokenize(Card card, String uniqueId) |
token service | The same, with your own request id. |
HealthResult testAuth() |
TESTAUTH |
Verify credentials without transacting. |
HealthResult testAvailability() |
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() take only the order reference here and
never send CREDIT_ON_FAIL, and there is no threeDSecure() sub-client. See
Reverse versus credit and
3D Secure for what to do instead.
Handling results
r.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:
TransactionResult r = client.sale(req);
show("status", r.status());
show("order", r.orderRef() == null ? "-" : r.orderRef().poId());
show("amount", r.amount() == null ? "-"
: r.amount().toWire() + " " + r.amount().currency());
switch (r.status()) {
case APPROVED:
show("next", "fulfil the order");
break;
case DECLINED:
// The service tier carries the decline taxonomy dunning needs.
boolean retry = r.serviceClassification() != null
&& r.serviceClassification().retryable();
show("next", retry ? "retry later" : "do not retry");
break;
case PENDING:
show("next", "complete "
+ (r.nextAction() == null ? "?" : r.nextAction().kind()));
break;
default:
show("next", "inspect result.outcome()");
}
Because the status enum is not compiler-checked for exhaustiveness on Java 11,
that default branch is doing real work. Do not omit it.
Reference keys sit flat on the result, not inside a nested bag, because they are
the ones you reach for most: client.capture(r.orderRef(), amount). The
available accessors 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 InovioException, itself a RuntimeException:
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
Options.siteKey. 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...
Tokenize.Result t = tokenClient().tokenize(
PaymentMethods.card(PAN, EXPIRY, CVV));
t.token().guid();
t.tokenReqId(); // quote this to support
// BIN metadata is best-effort — null when the BIN is not in the table.
t.card().brand;
t.card().bank;
// The token replaces the PAN ONLY: expiry (and CVV) still travel with it,
// which tokenize() carries forward for you.
TransactionRequest req = Harness.request("TOK", "10.00");
req.paymentMethod = t.token();
TransactionResult sale = client().sale(req);
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 static helpers on their own:
import com.inoviopay.gateway.Tokenize;
String timestamp = Tokenize.timestamp(); // YYYYMMDDHHMMSS UTC
String 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
This SDK carries the request-side and result-side 3DS surface, but not the server-leg driver.
What is here:
RequestParts.BrowserDataon a request, which the request builder maps to theP3DS_BROWSER_LANGUAGE,USER_AGENT_XTLandP3DS_BROWSER_HEADERfields. These are required for gateway 3DS: without them the gateway silently skips authentication entirely.- The challenge next action. When a result is
PENDINGbecause a challenge is required,r.nextAction().kind()is"threeDSChallenge"and the object carries the redirect URL, the JWT and the processor transaction id, which is everything your page needs to post into the visible iframe.
req.browser = new RequestParts.BrowserData(language, userAgent, acceptHeader);
TransactionResult r = client.sale(req);
if (r.status() == TransactionStatus.PENDING
&& r.nextAction() != null
&& "threeDSChallenge".equals(r.nextAction().kind())) {
r.nextAction().redirectUrl;
r.nextAction().jwt;
// Browser: POST jwt to redirectUrl in a visible iframe.
}
There is no client.threeDSecure() here. The prepare leg against
3dsrequest.cfm and the completion leg after the ACS challenge have to be
driven against the 3DS endpoints directly, or through
the PHP SDK, which implements both. This SDK's
BrowserData also omits the optional EMVCo device fields (colour depth,
screen dimensions, time-zone offset) that the PHP one carries.
Timeouts and reconciliation
A timeout means the state is unknown, not failed. The gateway may have approved the charge and lost the response.
try {
client.sale(req.idempotency("ORDER-555"));
} catch (GatewayTimeoutException e) {
log.warn(e.recoveryHint());
OrderStatus actual = client.status(Refs.xtlOrder(e.xtlOrderId()));
// Do NOT retry blindly — that risks a double charge.
}
idempotency("ORDER-555") 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:
// Build a multi-leg order: authorize 100, capture 60, refund 10.
TransactionResult order = Harness.seedOrder(c, "STATUS", false, "100.00");
c.capture(order.orderRef(), Money.of("60.00", "USD"));
c.refund(order.orderRef(), Money.of("10.00", "USD"));
OrderStatus s = c.status(order.orderRef());
s.transactions().size(); // every leg: auth, captures, refunds, voids
s.authorized().toWire();
s.captured().toWire();
s.refunded().toWire();
s.net().toWire(); // captured - refunded
s.outstanding().toWire(); // authorized - captured
// You can also look an order up by YOUR id:
// c.status(Refs.xtlOrder("ORDER-555"));
Running the conformance tests
mvn test # 19 conformance tests
python3 scripts/generate_enums.py # regenerate enums from spec/spec-enums.json
The suite replays the shared cross-language corpus in
spec/conformance-fixtures.json, the same fixtures the other three SDKs run,
which is what keeps the four honest about producing identically shaped results.
The enums under src/main/java/com/inoviopay/gateway/enums/ 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.
javac -cp target/classes -d target/examples examples/*.java \
&& java -cp target/classes:target/examples RunAll # all 14, mock transport
java -cp target/classes:target/examples Example03Sale # just one
| File | Operation |
|---|---|
Example01TestAvailability.java |
testAvailability(), the TESTGW health check |
Example02TestAuth.java |
testAuth(), credential verification with no transaction |
Example03Sale.java |
sale(), authorize and capture in one step |
Example04Authorize.java |
authorize(), holding funds and keeping the order ref |
Example05Capture.java |
capture(), full or partial |
Example06CaptureLineItem.java |
captureLineItem(), which needs order, item and amount |
Example07Reverse.java |
reverse(), voiding an authorization |
Example08ReverseCapture.java |
reverseCapture(), voiding a capture pre-settlement |
Example09Refund.java |
refund(), returning captured funds |
Example10ForceCredit.java |
forceCredit(), which needs MID provisioning |
Example11Status.java |
status(), reconciliation and net position |
Example12UpdateOrder.java |
updateOrder(), attaching receipts for compliance |
Example13Tokenize.java |
tokenize(), PAN to single-use token |
Example14TimeoutRecovery.java |
The pattern that prevents double charges |
Harness.java holds the shared mock transport and request builder;
RunAll.java runs the set.
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
java -cp target/classes:target/examples RunAll
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. Every example that operates on an existing order builds its own first, rather than hardcoding an id that resolves only against a mock.
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.