PayinferenceDocs
payinference.com

Node.js SDK

@payinference/sdk-node is the official server-side client. It validates requests before they leave your process, enforces a hard latency budget on decisions, retries network errors on reporting calls, and can build a safe local fallback decision when PayInference is unreachable.

Server-side only. The API key is a secret; this package must never run in a browser. For the checkout page, see Browser helpers.

Installation#

The SDK lives in this repository as a workspace package. Inside the monorepo (including the examples/ apps):

json
{ "dependencies": { "@payinference/sdk-node": "workspace:*" } }

Publication to the public npm registry is planned; until then, consume it via the workspace or a git dependency, or vendor the package. Its only runtime dependency is zod. Requires Node 18 or newer (built-in fetch).

Initialization#

ts
import { PayInferenceClient } from '@payinference/sdk-node';

const payinference = new PayInferenceClient({
  apiKey: process.env.PAYINFERENCE_API_KEY!,
  baseUrl: process.env.PAYINFERENCE_BASE_URL ?? 'http://localhost:4000',
  merchantId: 'm_123',
  timeoutMs: 50,
  reportingTimeoutMs: 2000,
  retries: 1,
  fallback: { provider: 'stripe', fallback_provider: 'adyen' },
});
Option Type Default Description
apiKey string required Secret key, pi_test_… or pi_live_…
baseUrl string http://localhost:4000 API origin for your environment
timeoutMs number 250 Hard per-request budget for decide()
reportingTimeoutMs number 2000 Timeout for recordOutcome() and getProviderHealth()
merchantId string none Default merchant_id merged into decide() calls
retries number 1 Network-error retries for reporting calls (never applied to HTTP responses, never to decide())
fallback object none Local fallback decision when PayInference is unreachable: { provider, fallback_provider? }
fetch function global fetch Injectable fetch, useful in tests

Creating a decision#

ts
const decision = await payinference.decide({
  order_id: 'order_456',
  transaction: {
    amount: 14900,
    currency: 'USD',
    country: 'US',
    payment_method: 'card',
    customer_type: 'returning',
  },
  available_providers: ['stripe', 'adyen', 'paypal'],
  // Optional on retries:
  // previous_attempt: { provider: 'stripe', outcome: 'timeout' },
});

switch (decision.action) {
  case 'route':
  case 'failover':
    await chargeWithProvider(decision.route!.primary_provider, order);
    break;
  case 'use_default_route':
    await chargeWithDefaultProvider(order);
    break;
  case 'step_up':
    await startStepUp(order, decision.decision_id);
    break;
  case 'retry':
    await retryWithPolicy(order, decision.route);
    break;
  case 'block':
    rejectPayment(order, decision.decision_id, decision.reason_codes);
    break;
}

Input is validated locally with the same field rules the server enforces; invalid input throws PayInferenceValidationError before any request is made.

Idempotent retries#

Pass an idempotency key per attempt so a retried call replays the originally stored decision instead of creating a new one:

ts
const decision = await payinference.decide(params, { idempotencyKey: attemptId });

See Idempotency and retries for the replay semantics.

Sending outcome feedback#

ts
await payinference.recordOutcome({
  decision_id: decision.decision_id,
  order_id: 'order_456',
  provider_used: 'adyen',
  outcome: 'approved', // see Outcome feedback for the value set
  provider_latency_ms: 842,
  provider_reason_code: 'approved',
  amount: 14900,
  currency: 'USD',
});

Idempotent per decision_id, retried on network errors, and safe to fire and forget:

ts
void payinference.recordOutcome(params).catch(() => undefined);

Wrapping your PSP calls (attempt telemetry)#

Opt-in: wrap the PSP call your backend makes so PayInference learns real API latency, timeouts, and technical errors it can't see from webhooks. The SDK never executes PSP logic — your function runs exactly once and its result or exception is returned or rethrown unchanged.

ts
const intent = await payinference.wrapProviderAttempt(
  {
    provider: 'stripe',
    environment: 'live',
    operation: 'authorization',
    payment_method: 'card',
    currency: 'USD',
    amount_minor: 14900,
  },
  () => stripe.paymentIntents.confirm(intentId, params),
  {
    // Optional: map 200-declines so they never count against provider health.
    classifyResult: (r) =>
      r.status === 'requires_payment_method'
        ? { outcome_category: 'issuer_or_customer_decline' }
        : undefined,
  },
);

Telemetry is fire-and-forget with a bounded timeout (reportingTimeoutMs): a PayInference outage never fails or slows your payment flow. Nothing from your PSP request or response is read or transmitted — only the metadata you pass and the classification you return. Known Node timeout/network errors are classified automatically; anything else reports as unknown_failure (never blamed on the provider). For calls you measure yourself there is recordProviderAttempt(params) against POST /v1/provider-attempts (idempotent per attempt_id).

OpenTelemetry attempt telemetry#

If your backend is already traced with OpenTelemetry, you can report PSP attempt telemetry without wrapping individual calls: add PayInferenceSpanProcessor to your NodeSDK. It is a standard SpanProcessor (structural types — the SDK adds no OpenTelemetry dependency).

ts
import { PayInferenceClient, PayInferenceSpanProcessor } from '@payinference/sdk-node';
import { NodeSDK } from '@opentelemetry/sdk-node';

const payinference = new PayInferenceClient({
  apiKey: process.env.PAYINFERENCE_KEY, // scope: outcome:write
  merchantId: 'm_…',
});

const sdk = new NodeSDK({
  spanProcessors: [
    new PayInferenceSpanProcessor(payinference, {
      environment: 'live',
      hosts: ['api.stripe.com', 'api.regionalpay.example'],
    }),
  ],
});

Privacy and safety guarantees:

  • Allowlist-only capture. Only spans whose hostname / peer.service you list are reduced; everything else — including its hostname — never leaves your process. There is deliberately no capture-everything mode.
  • Hostname-only URLs. Paths, query strings, ports and credentials are discarded unread; headers, bodies and other span attributes are never touched. Each report is validated against the strict schema before it is buffered.
  • Fire-and-forget batches (bounded size and buffer) to POST /v1/provider-attempts/otel: a PayInference outage can never throw into a traced request or grow memory.
  • Server-side attribution. Spans are matched to providers by exact host/service match (generic providers use the matchers from Generic PSP Setup; native providers match their fixed per-environment API hostnames). Unmatched or ambiguous spans are skipped; a span whose declared test/live label contradicts an environment-bound hostname is rejected with environment_conflict, never trusted.

Per-span overrides are available via payinference.* span attributes (payinference.environment, payinference.provider, payinference.operation, payinference.provider_account_ref); invalid values fall back to the processor's configuration. http.request.resend_count is reported as the retry count. For custom pipelines, reduceOtelSpan (the pure reduction) and client.reportOtelSpans(spans) are exported directly.

Reading provider health#

ts
const { providers } = await payinference.getProviderHealth();
// [{ provider: 'adyen', state: 'healthy', score: 94, … }]

Timeouts, retries, and the local fallback#

The client's behavior under failure is deliberate and asymmetric:

  • HTTP responses are answers. Any non-2xx throws PayInferenceAPIError and is never retried.
  • Network errors and timeouts are outages. Reporting calls retry them up to retries times. decide() does not retry; a checkout should not sit through multiple timeout windows.
  • The fallback covers decisions. With fallback configured, an unreachable or over-budget decide() resolves to a locally built decision instead of throwing:
json
{
  "decision_id": "dec_sdk_fallback_…",
  "action": "use_default_route",
  "route": { "primary_provider": "stripe", "fallback_provider": "adyen" },
  "model": { "model_version": "sdk-fallback", "route_scores": {} },
  "reason_codes": ["SDK_FALLBACK_USED", "PAYINFERENCE_TIMEOUT"],
  "sdk_fallback": true
}

Check decision.sdk_fallback when you need to distinguish real decisions from local ones, and alert if fallback usage is sustained. Configure the fallback provider per your own risk posture; see Production integration.

Error handling#

ts
import {
  PayInferenceAPIError,
  PayInferenceNetworkError,
  PayInferenceTimeoutError,
  PayInferenceValidationError,
} from '@payinference/sdk-node';

try {
  const decision = await payinference.decide(params);
} catch (error) {
  if (error instanceof PayInferenceValidationError) {
    // error.issues: [{ path: 'transaction.amount', message: '…' }]. No request was made.
  } else if (error instanceof PayInferenceAPIError) {
    // error.status, error.body. The API answered; do not retry.
  } else if (error instanceof PayInferenceTimeoutError) {
    // error.timeoutMs exceeded. Only thrown when no fallback is configured.
  } else if (error instanceof PayInferenceNetworkError) {
    // DNS/connection failure. Only thrown when no fallback is configured.
  }
}

TypeScript types#

The package exports the full type surface: Decision, DecisionRoute, DecideParams, RecordOutcomeParams, OutcomeResult, ProviderHealthEntry, Provider, PayInferenceClientOptions, and FallbackConfig. Decision['action'] is the union 'route' | 'step_up' | 'hold' | 'block' | 'retry' | 'failover' | 'use_default_route', so an exhaustive switch gets compiler coverage of every instruction.

Serverless integration#

The client is a thin wrapper over fetch with no connection pooling or background state, so it works in serverless functions as-is:

  • Construct the client once at module scope, not per invocation, so cold starts pay the cost once.
  • Keep timeoutMs well under your function's own timeout, and configure fallback so a PayInference outage cannot consume your invocation budget.
  • Report outcomes before returning, or hand them to your queue; a fire-and-forget promise may be frozen when a serverless runtime suspends after the response. The examples/nextjs-server-checkout app shows a route-handler integration.

Testing locally#

  • Point baseUrl at a local instance (pnpm dev, seeded demo key pi_demo_key_123, merchant m_123).
  • Inject fetch to unit-test your handler without a network:
ts
const client = new PayInferenceClient({
  apiKey: 'pi_test_x',
  fetch: async () => new Response(JSON.stringify(fakeDecision), { status: 200 }),
});
  • To test your outage path, point baseUrl at a blackhole and assert your handler executes the fallback.