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):
{ "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#
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#
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:
const decision = await payinference.decide(params, { idempotencyKey: attemptId });
See Idempotency and retries for the replay semantics.
Sending outcome feedback#
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:
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.
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).
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.serviceyou 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/livelabel contradicts an environment-bound hostname is rejected withenvironment_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#
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
PayInferenceAPIErrorand is never retried. - Network errors and timeouts are outages. Reporting calls retry them up to
retriestimes.decide()does not retry; a checkout should not sit through multiple timeout windows. - The fallback covers decisions. With
fallbackconfigured, an unreachable or over-budgetdecide()resolves to a locally built decision instead of throwing:
{
"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#
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
timeoutMswell under your function's own timeout, and configurefallbackso 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-checkoutapp shows a route-handler integration.
Testing locally#
- Point
baseUrlat a local instance (pnpm dev, seeded demo keypi_demo_key_123, merchantm_123). - Inject
fetchto unit-test your handler without a network:
const client = new PayInferenceClient({
apiKey: 'pi_test_x',
fetch: async () => new Response(JSON.stringify(fakeDecision), { status: 200 }),
});
- To test your outage path, point
baseUrlat a blackhole and assert your handler executes the fallback.