Node SDK
The Inovio gateway SDK for Node 18 and TypeScript, with a typed promise-based API and no runtime dependencies.
The Inovio payment gateway for Node. Card transactions covering authorize, capture, refund and tokenize, with a typed, promise-based API and no runtime dependencies.
This is the reference implementation. It defined the canonical method surface, the naming and the conformance fixtures that the PHP, Python and Java SDKs were ported against, so where the four disagree, this is the intended shape.
Version 0.1.0-alpha and not published to npm. Install from the public GitHub repository as shown below, and pin a commit until a tagged release lands.
Status and install
npm installs directly from the GitHub repository:
npm install github:Inoviopay/inovio-gateway-sdk-node
Pin a commit rather than tracking main while the package is alpha:
npm install github:Inoviopay/inovio-gateway-sdk-node#<commit-sha>
The package.json sets "private": true, which blocks an accidental
npm publish before the package is ready. It does not affect installing from a
git URL.
The repository ships TypeScript sources and main points at dist/, but
there is no prepare script, so npm install github:... leaves you without a
compiled dist/. Until a build step is wired in, clone and build:
git clone <https://github.com/Inoviopay/inovio-gateway-sdk-node.git>
cd inovio-gateway-sdk-node
npm install && npm run build
Then depend on it with npm install /path/to/inovio-gateway-sdk-node, or run
npm link in the clone followed by npm link @inovio/gateway-sdk in your
project.
Requirements
Node 18 or newer. Zero runtime dependencies: HTTP goes through the built-in
fetch, and the flat key-value response is parsed without a JSON library
beyond the platform's own.
The package is ESM ("type": "module"), with .d.ts declarations shipped
alongside. Import it with import, not require. From a CommonJS file, use a
dynamic import:
const { InovioClient } = await import('@inovio/gateway-sdk');
Quick start
import { InovioClient, Money, PaymentMethods, Refs } from '@inovio/gateway-sdk';
const client = new InovioClient(
{ reqUsername: process.env.INOVIO_USER!, reqPassword: process.env.INOVIO_PASS!, siteId: '123' },
{ environment: 'SANDBOX' }
);
const result = await client.sale({
paymentMethod: PaymentMethods.card('4111111111111111', '122030', '123'),
lineItems: [{ productId: 'SKU-1', count: 1, value: Money.of('10.00', 'USD') }],
idempotency: { xtlOrderId: 'ORDER-555' }, // retry-safe by default
});
switch (result.status) {
case 'APPROVED': /* fulfil */ break;
case 'DECLINED': /* result.outcome.service, result.serviceClassification */ break;
case 'PENDING': /* result.nextAction — 3DS challenge, redirect, voucher */ break;
case 'RUNNING':
case 'FAILED': break;
}
The second constructor argument is a ClientOptions object. Everything on it is
optional:
const client = new InovioClient(creds, {
environment: 'PRODUCTION',
endpoint: 'http://localhost:8080/payment/pmt_service.cfm', // overrides environment
apiVersion: '4.14',
timeoutMs: 30_000,
httpClient: myInstrumentedClient,
siteKey: process.env.INOVIO_SITE_KEY, // required only for tokenize()
});
Node-specific notes
Requests are plain object literals. There is no request builder class.
sale() and authorize() take a SaleRequest / AuthorizeRequest interface,
so you write the object inline and TypeScript checks it. That is why the quick
start passes lineItems as an array of literals rather than constructing a
LineItem.
Money refuses JavaScript numbers. Money.of(1.25, 'USD') throws a
TypeError; pass '1.25'. Binary floats cannot represent decimal amounts
exactly, and the wire format is a decimal string, so rounding has to be your
explicit decision.
The timeout error is TimeoutError, a subclass of TransportError. It
carries xtlOrderId and a recoveryHint getter.
Payment methods are a discriminated union. PaymentMethods.card(),
.token() and .savedCard() return objects tagged with a kind field, so a
switch over a payment method narrows correctly. The variants declared but not
implemented in v1 are rejected at request-build time with a clear message rather
than sent to the gateway.
References are branded types. OrderRef and friends are structurally
{ poId: string } but branded, so a plain object literal will not type-check
where a ref is expected. Build them with the Refs factory:
Refs.order('18800001'), Refs.xtlOrder('ORDER-555'), Refs.lineItem('9000001').
Operations
Every public method on InovioClient, with the REQUEST_ACTION it maps to. All
of them return a promise.
| Method | Action | Notes |
|---|---|---|
sale(req: SaleRequest): Promise<TransactionResult> |
CCAUTHCAP |
Authorize and capture in one step. See Sale. |
authorize(req: AuthorizeRequest): Promise<TransactionResult> |
CCAUTHORIZE |
Hold funds; capture later. See Authorize. |
capture(order: OrderRef, amount?: Money): Promise<TransactionResult> |
CCCAPTURE |
Omit the amount to capture in full. See Capture. |
captureLineItem(order: OrderRef, item: LineItemRef, amount: Money): Promise<TransactionResult> |
CCCAPTURE |
All three arguments are required. |
reverse(order: OrderRef): Promise<TransactionResult> |
CCREVERSE |
Void the original authorization. See Reversal. |
reverseCapture(order: OrderRef): Promise<TransactionResult> |
CCREVERSECAP |
Void a capture rather than the original auth. |
refund(order: OrderRef, amount?: Money): Promise<TransactionResult> |
CCCREDIT |
Omit the amount to refund in full. See Credit. |
forceCredit(req: CreditRequest): Promise<TransactionResult> |
CCCREDIT + FORCE_CREDIT |
Credit with no referenced original. Needs MID provisioning. |
status(ref: OrderRef \| XtlOrderId): Promise<OrderStatus> |
CCSTATUS |
Order-level net position. See Status. |
updateOrder(order: OrderRef, update: OrderUpdate): Promise<TransactionResult> |
CCTRANSUPDATE |
Attach a receipt and UDF metadata. |
tokenize(card: Card, options?: { uniqueId?: string }): Promise<TokenizeResult> |
token service | Needs siteKey. See Tokenization. |
testAuth(): Promise<HealthResult> |
TESTAUTH |
Verify credentials without transacting. |
testAvailability(): Promise<HealthResult> |
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. Omitting the
amount throws a ValidationError locally rather than letting the gateway reject
it. Both behaviours were 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
result.status carries the answer. A decline resolves normally with
status: 'DECLINED'; rejections are reserved for cases where you never got a
payment answer at all.
const result = await client.sale({
paymentMethod: PaymentMethods.card(pan, expiry, cvv),
lineItems: [{ productId: 'SKU-1', count: 1, value: Money.of('10.00', 'USD') }],
customer, billingAddress,
// Setting an order id makes the call retry-safe: a repeat returns the
// original result instead of charging twice.
idempotency: { xtlOrderId: 'ORDER-555' },
});
switch (result.status) {
case 'APPROVED':
// fulfil the order
break;
case 'DECLINED':
// The service tier carries the decline taxonomy your dunning logic needs.
if (result.serviceClassification?.retryable) { /* retry later */ }
break;
case 'PENDING':
// complete result.nextAction.kind
break;
default:
// 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.orderRef, amount). The
available refs are orderRef, xtlOrderRef, transactionId, requestId,
batchId, customerRef, savedCardRef, membershipRef and lineItemRefs.
The gateway sends codes; the SDK adds labels. result.serviceClassification.retryable,
.terminal and .stopRecurring, 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.
Errors extend InovioError: AuthenticationError, ValidationError (carrying
the offending refField), ConfigurationError, TransportError,
TimeoutError (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 siteKey
in ClientOptions. Without it the call throws a ValidationError before any
network traffic, and the service itself would answer error 121.
// Tokenize on the site that holds the HMAC key...
const t = await tokenClient().tokenize(PaymentMethods.card(pan, expiry, cvv));
t.token.guid;
t.tokenReqId; // quote this to support
// BIN metadata is best-effort — undefined when the BIN is not in the table.
[t.card.brand, t.card.type, t.card.bank].filter(Boolean).join(' / ');
// The token replaces the PAN ONLY: expiry (and CVV) still travel with it, which
// tokenize() carries forward for you.
// ...then transact on the gateway site.
const sale = await client().sale({
paymentMethod: t.token,
lineItems: [{ productId: 'SKU-1', count: 1, value: Money.of('10.00', 'USD') }],
customer, billingAddress,
idempotency: { xtlOrderId: 'ORDER-556' },
});
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:
import { signTokenRequest, tokenTimestamp, verifyTokenResponse } from '@inovio/gateway-sdk';
const timestamp = tokenTimestamp(); // YYYYMMDDHHMMSS UTC
const signature = signTokenRequest(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:
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
nextAction. When a result isPENDINGbecause a challenge is required,result.nextActionnarrows to{ kind: 'threeDSChallenge'; redirectUrl?; jwt?; procTransId?; pareq? }, which is everything your page needs to post into the visible iframe.
if (result.status === 'PENDING' && result.nextAction?.kind === 'threeDSChallenge') {
const { redirectUrl, jwt } = result.nextAction;
// 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.
Timeouts and reconciliation
A timeout means the state is unknown, not failed. The gateway may have approved the charge and lost the response.
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
}
}
Idempotency defaults to RETURN_ORIGINAL when xtlOrderId is set, so a retry
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.
const order = await seedOrder(c, 'STATUS', { amount: '100.00' });
await c.capture(order.orderRef, Money.of('60.00', 'USD'));
await c.refund(order.orderRef, Money.of('10.00', 'USD'));
const s = await c.status(order.orderRef);
s.transactions.length; // every leg: auth, captures, refunds, voids
s.authorized?.amount;
s.captured?.amount;
s.refunded?.amount;
s.net?.amount; // captured - refunded
s.outstanding?.amount; // authorized - captured
// You can also look an order up by YOUR id:
// await c.status(Refs.xtlOrder('ORDER-555'));
Running the conformance tests
npm install
npm run generate # regenerate enums from spec/spec-enums.json
npm run build
npm test
npm test runs both suites through the Node test runner. npm run conformance
runs only the cross-language corpus in spec/conformance-fixtures.json, the
same fixtures the other three SDKs replay, which is what keeps the four honest
about producing identically shaped results. npm run typecheck type-checks
without emitting.
The enums in src/enums/generated.ts 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.
npm run examples # all 14, against a mock transport
node examples/03-sale.mjs # just one
| File | Operation |
|---|---|
01-test-availability.mjs |
testAvailability(), the TESTGW health check |
02-test-auth.mjs |
testAuth(), credential verification with no transaction |
03-sale.mjs |
sale(), authorize and capture in one step |
04-authorize.mjs |
authorize(), holding funds and keeping the orderRef |
05-capture.mjs |
capture(), full or partial |
06-capture-line-item.mjs |
captureLineItem(), which needs order, item and amount |
07-reverse.mjs |
reverse(), voiding an authorization |
08-reverse-capture.mjs |
reverseCapture(), voiding a capture pre-settlement |
09-refund.mjs |
refund(), returning captured funds |
10-force-credit.mjs |
forceCredit(), which needs MID provisioning |
11-status.mjs |
status(), reconciliation and net position |
12-update-order.mjs |
updateOrder(), attaching receipts for compliance |
13-tokenize.mjs |
tokenize(), PAN to single-use token |
14-timeout-recovery.mjs |
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=... \
npm run examples
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 arrives as an AuthenticationError rather than a
decline. tokenize needs the per-site HMAC key from Inovio support.