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

Source: https://developer.inoviopay.com/sdks/concepts.html  
Markdown: https://developer.inoviopay.com/sdks/concepts.md

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](https://developer.inoviopay.com/sdks/index.md).

## 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](https://developer.inoviopay.com/api/sale.md). |
| `authorize` | `CCAUTHORIZE` | Hold funds; capture later. See [Authorize](https://developer.inoviopay.com/api/authorize.md). |
| `capture` | `CCCAPTURE` | Capture an authorization, in full or in part. See [Capture](https://developer.inoviopay.com/api/capture.md). |
| `captureLineItem` | `CCCAPTURE` | Capture one line item. Needs the parent order, the item, and an amount. |
| `reverse` | `CCREVERSE` | Void the original authorization. See [Reversal](https://developer.inoviopay.com/api/reversal.md). |
| `reverseCapture` | `CCREVERSECAP` | Void a capture rather than the original auth. |
| `refund` | `CCCREDIT` | Refund against an existing order, in full or in part. See [Credit](https://developer.inoviopay.com/api/credit.md). |
| `forceCredit` | `CCCREDIT` + `FORCE_CREDIT` | Credit with no referenced original. Needs MID provisioning. |
| `status` | `CCSTATUS` | Order-level net position and unknown-state recovery. See [Status](https://developer.inoviopay.com/api/status.md). |
| `updateOrder` | `CCTRANSUPDATE` | Attach receipts to an existing order. |
| `tokenize` | token service | Exchange a PAN for a single-use token. See [Tokenization](https://developer.inoviopay.com/api/tokenization.md). |
| `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"`.

**PHP**

```php
use Inovio\Gateway\Model\Money;

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

**Node**

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

**Python**

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

**Java**

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

**PHP**

```php
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
}
```

**Node**

```ts
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
  }
}
```

**Python**

```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))
    # a blind retry here could double-charge
```

**Java**

```java
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](https://developer.inoviopay.com/reference/api-codes.md). |
| Service | `SERVICE_RESPONSE` / `SERVICE_ADVICE` | Gateway transaction outcome and the decline taxonomy. See [Service codes](https://developer.inoviopay.com/reference/service-codes.md). |
| 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](https://developer.inoviopay.com/reference/avs-codes.md) and [CVV codes](https://developer.inoviopay.com/reference/cvv-codes.md).

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](https://developer.inoviopay.com/sdks/index.md#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](https://developer.inoviopay.com/api/3ds-gateway.md) directly.

Here is the PHP flow end to end:

```php
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](https://developer.inoviopay.com/api/3ds-external.md). 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](https://developer.inoviopay.com/api/reversal.md) 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
// 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.
