GitHub Markdown

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.

The SDK is alpha

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.

A git install does not build the package

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.

Two capabilities are PHP-only in this alpha

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

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 nextAction. When a result is PENDING because a challenge is required, result.nextAction narrows 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.
}
The 3DS server legs are PHP-only in this alpha

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.