Python SDK
The Inovio gateway SDK for Python 3.8, synchronous, typed, and built on the standard library alone.
The Inovio payment gateway for Python. Card transactions covering authorize, capture, refund and tokenize, with a typed, synchronous API and no third-party dependencies.
Version 0.1.0-alpha and not published to PyPI. Install from the public GitHub repository as shown below, and pin a commit until a tagged release lands.
Status and install
pip installs directly from the GitHub repository:
pip install "git+https://github.com/Inoviopay/inovio-gateway-sdk-python.git@main"
Pin a commit rather than tracking main while the package is alpha:
pip install "git+https://github.com/Inoviopay/inovio-gateway-sdk-python.git@<commit-sha>"
In a requirements.txt:
inovio-gateway-sdk @ git+https://github.com/Inoviopay/inovio-gateway-sdk-python.git@main
Or as a pyproject.toml dependency:
[project]
dependencies = [
"inovio-gateway-sdk @ git+https://github.com/Inoviopay/inovio-gateway-sdk-python.git@main",
]
The distribution is a standard setuptools build from src/, so a git install
needs no extra flags.
Requirements
Python 3.8 or newer, standard library only. HTTP goes through urllib.
The client is synchronous. An async client was deliberately deferred until a partner asks for one, so every call blocks. If you need it inside an async application, run it in a thread executor.
py.typed ships in the package, so type checkers see the annotations rather
than falling back to Any.
Quick start
from inovio_gateway import (
Credentials, InovioClient, Money, PaymentMethods, Refs, TransactionStatus,
)
from inovio_gateway.model import Idempotency, LineItem
from inovio_gateway.request import TransactionRequest
client = InovioClient(Credentials(user, password, site_id="123"), environment="SANDBOX")
result = client.sale(TransactionRequest(
payment_method=PaymentMethods.card("4111111111111111", "122030", "123"),
line_items=[LineItem("SKU-1", 1, Money.of("10.00", "USD"))],
idempotency=Idempotency(xtl_order_id="ORDER-555"), # retry-safe by default
))
if result.status is TransactionStatus.APPROVED:
...
elif result.status is TransactionStatus.PENDING:
result.next_action # 3DS challenge, redirect, voucher
Everything after the credentials is a keyword argument:
client = InovioClient(
Credentials(user, password, site_id="123"),
environment="PRODUCTION",
endpoint="http://localhost:8080/payment/pmt_service.cfm", # overrides environment
timeout_ms=30_000,
http_client=my_instrumented_client,
site_key=os.environ["INOVIO_SITE_KEY"], # required only for tokenize()
)
Python-specific notes
Method names are snake_case. The shared object model's captureLineItem
becomes capture_line_item, reverseCapture becomes reverse_capture,
forceCredit becomes force_credit, updateOrder becomes update_order, and
testAuth / testAvailability become test_auth / test_availability. Result
fields follow the same rule: order_ref, xtl_order_ref, next_action,
service_classification, line_item_refs.
Amounts are decimal.Decimal internally. Money.of accepts a Decimal,
a str or an int, and rejects float. Money.of(1.25, "USD") raises a
TypeError; pass "1.25" or Decimal("1.25").
The timeout exception is InovioTimeoutError, deliberately not named
TimeoutError. Callers routinely catch the builtin of that name, and shadowing
it would hide exactly the unknown-state case that needs status() recovery. It
subclasses TransportError and carries xtl_order_id and recovery_hint.
Status is an enum, not a string. result.status is a TransactionStatus
member, so compare with is rather than ==, and read result.status.value
when you need the wire string for logging.
Requests and model objects are dataclasses. TransactionRequest takes
keyword arguments at construction and its optional blocks are assigned
afterwards:
req = TransactionRequest(
payment_method=PaymentMethods.card(pan, expiry, cvv),
line_items=[LineItem("SKU-1", 1, Money.of("10.00", "USD"))],
)
req.customer = Customer(first_name="Ada", last_name="Lovelace",
email="ada@example.invalid", ip="203.0.113.10")
req.billing_address = Address(line1="123 Main St", city="Austin", state="TX",
zip="78701", country="US")
req.idempotency = Idempotency(xtl_order_id="ORDER-555")
Operations
Every public method on InovioClient, with the REQUEST_ACTION it maps to:
| Method | Action | Notes |
|---|---|---|
sale(req: TransactionRequest) -> TransactionResult |
CCAUTHCAP |
Authorize and capture in one step. See Sale. |
authorize(req: TransactionRequest) -> TransactionResult |
CCAUTHORIZE |
Hold funds; capture later. See Authorize. |
capture(order: OrderRef, amount: Optional[Money] = None) -> TransactionResult |
CCCAPTURE |
Omit the amount to capture in full. See Capture. |
capture_line_item(order: OrderRef, item: LineItemRef, amount: Money) -> TransactionResult |
CCCAPTURE |
All three arguments are required. |
reverse(order: OrderRef) -> TransactionResult |
CCREVERSE |
Void the original authorization. See Reversal. |
reverse_capture(order: OrderRef) -> TransactionResult |
CCREVERSECAP |
Void a capture rather than the original auth. |
refund(order: OrderRef, amount: Optional[Money] = None) -> TransactionResult |
CCCREDIT |
Omit the amount to refund in full. See Credit. |
force_credit(req: TransactionRequest) -> TransactionResult |
CCCREDIT + FORCE_CREDIT |
Credit with no referenced original. Needs MID provisioning. |
status(ref: Union[OrderRef, XtlOrderId]) -> OrderStatus |
CCSTATUS |
Order-level net position. See Status. |
update_order(order: OrderRef, update: OrderUpdate) -> TransactionResult |
CCTRANSUPDATE |
Attach a receipt and UDF metadata. |
tokenize(card: Card, unique_id: Optional[str] = None) -> TokenizeResult |
token service | Needs site_key. See Tokenization. |
test_auth() -> HealthResult |
TESTAUTH |
Verify credentials without transacting. |
test_availability() -> HealthResult |
TESTGW |
Verify gateway availability. Safe to poll. |
capture_line_item() 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. Omitting the
amount raises a ValidationError locally rather than letting the gateway reject
it. Both behaviours were verified against the live gateway.
reverse() and reverse_capture() take only the order reference here and
never send CREDIT_ON_FAIL, and there is no three_d_secure() sub-client.
See Reverse versus credit and
3D Secure for what to do instead.
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/except for the
payment outcome:
from inovio_gateway import TransactionStatus
result = client.sale(req)
print(result.status.value)
print(result.order_ref.po_id if result.order_ref else "-")
print(f"{result.amount.to_wire()} {result.amount.currency}" if result.amount else "-")
if result.status is TransactionStatus.APPROVED:
... # fulfil the order
elif result.status is TransactionStatus.DECLINED:
# The service tier carries the decline taxonomy your dunning logic needs.
retryable = result.service_classification and result.service_classification.retryable
... # retry later, or do not retry
elif result.status is TransactionStatus.PENDING:
... # complete result.next_action.kind
else:
... # inspect result.outcome
Reference keys sit flat on the result, not inside a nested bag, because they are
the fields you reach for most: client.capture(result.order_ref, amount). The
available refs are order_ref, xtl_order_ref, transaction_id, request_id,
batch_id, customer_ref, saved_card_ref, membership_ref and
line_item_refs.
The gateway sends codes; the SDK adds labels.
result.service_classification.retryable, .terminal and .stop_recurring,
plus result.avs.classification, are labels the SDK derives from the response
codes, not values the gateway sent. The codes themselves are on
result.outcome and the untouched wire fields on result.raw. See
Outcome tiers.
Exceptions extend InovioError: AuthenticationError, ValidationError
(carrying the offending ref_field), ConfigurationError, TransportError,
InovioTimeoutError (a subclass of TransportError) and RateLimitError.
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 site_key
on the client. Without it the call raises a ValidationError before any network
traffic, and the service itself would answer error 121.
# Tokenize on the site that holds the HMAC key...
t = token_client().tokenize(PaymentMethods.card(demo.pan, demo.expiry, demo.cvv))
t.token.guid
t.token_req_id # quote this to support
# BIN metadata is best-effort — None when the BIN is not in the lookup table.
bits = [b for b in (t.card.brand, t.card.type, t.card.bank) if b]
" / ".join(bits) or "(BIN not found)"
# The token replaces the PAN ONLY: expiry (and CVV) still travel with it, which
# tokenize() carries forward for you.
sale = client().sale(request(tag="TOK", payment_method=t.token))
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 exported helpers on their own:
from inovio_gateway import sign_token_request, token_timestamp
timestamp = token_timestamp() # YYYYMMDDHHMMSS UTC
signature = sign_token_request(site_key, timestamp, unique_id, site_id)
# Return {signature, timestamp, site_id} 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:
BrowserDataon a request, which the request builder maps to theP3DS_BROWSER_LANGUAGE,USER_AGENT_XTLandP3DS_BROWSER_HEADERfields plus the optional EMVCo device fields. 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,result.next_action.kindis"threeDSChallenge"and the object carriesredirect_url,jwtandproc_trans_id, which is everything your page needs to post into the visible iframe.
if (result.status is TransactionStatus.PENDING
and result.next_action
and result.next_action.kind == "threeDSChallenge"):
result.next_action.redirect_url
result.next_action.jwt
# Browser: POST jwt to redirect_url in a visible iframe.
There is no client.three_d_secure() 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.
Timeouts and reconciliation
A timeout means the state is unknown, not failed. The gateway may have approved the charge and lost the response.
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))
# Do NOT retry blindly — that risks a double charge.
Setting Idempotency(xtl_order_id=...) 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.
order = seed_order(c, "STATUS", amount="100.00")
c.capture(order.order_ref, Money.of("60.00", "USD"))
c.refund(order.order_ref, Money.of("10.00", "USD"))
s = c.status(order.order_ref)
len(s.transactions) # every leg: auth, captures, refunds, voids
s.authorized.to_wire()
s.captured.to_wire()
s.refunded.to_wire()
s.net.to_wire() # captured - refunded
s.outstanding.to_wire() # authorized - captured
# You can also look an order up by YOUR id:
# c.status(Refs.xtl_order("ORDER-555"))
Refs exposes order(), xtl_order() and line_item() in this SDK.
Running the conformance tests
PYTHONPATH=src python3 -m unittest discover -s tests
python3 scripts/generate_enums.py # regenerate enums
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 in src/inovio_gateway/enums/generated.py 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.
PYTHONPATH=src python3 examples/run_all.py # all 14, against a mock transport
PYTHONPATH=src python3 examples/03_sale.py # just one
| File | Operation |
|---|---|
01_test_availability.py |
test_availability(), the TESTGW health check |
02_test_auth.py |
test_auth(), credential verification with no transaction |
03_sale.py |
sale(), authorize and capture in one step |
04_authorize.py |
authorize(), holding funds and keeping the order ref |
05_capture.py |
capture(), full or partial |
06_capture_line_item.py |
capture_line_item(), which needs order, item and amount |
07_reverse.py |
reverse(), voiding an authorization |
08_reverse_capture.py |
reverse_capture(), voiding a capture pre-settlement |
09_refund.py |
refund(), returning captured funds |
10_force_credit.py |
force_credit(), which needs MID provisioning |
11_status.py |
status(), reconciliation and net position |
12_update_order.py |
update_order(), attaching receipts for compliance |
13_tokenize.py |
tokenize(), PAN to single-use token |
14_timeout_recovery.py |
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
PYTHONPATH=src python3 examples/run_all.py
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.
force_credit 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.