# Python SDK

The Inovio gateway SDK for Python 3.8, synchronous, typed, and built on the standard library alone.

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

The Inovio payment gateway for Python. Card transactions covering authorize,
capture, refund and tokenize, with a typed, synchronous API and no third-party
dependencies.

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

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

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

```toml
[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

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

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

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

> **Two capabilities are PHP-only in this alpha**
> `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](https://developer.inoviopay.com/sdks/concepts.md#reverse-versus-credit) and
> [3D Secure](https://developer.inoviopay.com/sdks/concepts.md#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:

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

```python
# 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:

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

> **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

This SDK carries the request-side and result-side 3DS surface, but not the
server-leg driver.

What is here:

- **`BrowserData`** on a request, which the request builder maps to the
  `P3DS_BROWSER_LANGUAGE`, `USER_AGENT_XTL` and `P3DS_BROWSER_HEADER` fields
  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 `PENDING` because a challenge
  is required, `result.next_action.kind` is `"threeDSChallenge"` and the object
  carries `redirect_url`, `jwt` and `proc_trans_id`, which is everything your
  page needs to post into the visible iframe.

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

> **The 3DS server legs are PHP-only in this alpha**
> 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](https://developer.inoviopay.com/api/3ds-gateway.md) directly, or through
> the [PHP SDK](https://developer.inoviopay.com/sdks/php.md#3d-secure), 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.

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

```python
# 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

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

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

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