node-sispnode-sisp
Beta

@akira-io/sisp beta documentation. APIs may change before the stable release.

Adapters

This page covers HTTP framework adapters (Express, Fastify, NestJS). For ORM-level adapters such as Prisma, see Storage Adapters.

The core exposes pure handlers (sisp.handlers.*) that take a normalized request and return { type: 'html' | 'json' | 'redirect', ... }. HTTP adapters only translate framework requests and responses, so all three mount the same routes:

MethodRoutePurpose
POST/paymentValidate, persist, and render the gateway form as HTML (auto-submitting)
POST/payment/intentSame validate + persist, but return the gateway target as JSON for a SPA
GET, POST/callbackPayment result page and gateway notification
GET, POST/retry-paymentSigned retry flow
GET/cancelSigned cancel flow
GET, POST/sandboxLocal fake gateway (sandbox mode only)
GET/countriesISO country catalog with numeric codes and flags
GET/transactions/:refTransaction status as JSON ({ ref, status, amount, messageType, detail, error }); 404 if unknown, 429 past the per-IP limit, 403 when authorizeTransactionStatus denies it (allowed by default; the hook runs after the per-IP limit)
POST/refund/:transactionRefund, 429 past the per-IP limit, denied unless authorizeRefund allows it (the hook runs after the per-IP limit)

Mount the adapter at basePath (default /sisp) so the signed URLs and the sandbox endpoint resolve correctly. Express and Fastify mount wherever you register them; the Nest module reads basePath off the instance and mounts its controller there for you.

/payment versus /payment/intent

Both run the exact same pipeline (validate, idempotency, persist the transaction and first attempt, sign the request). They differ only in the response and in who triggers the redirect to the gateway:

POST /paymentPOST /payment/intent
ResponseHTML auto-submitting formJSON { action, fields, ref }
Redirect to the gatewayAutomatic, the browser submits the returned formYour frontend builds the form and submits it
Use it fromA native full-page form post (server-rendered app, or a SPA that lets the browser navigate)fetch/XHR in a SPA (React, Vue, Svelte)
Works with fetch?No, a fetched HTML string will not navigate and its inline script will not runYes

Rule of thumb: a SPA that calls the backend with fetch must use /payment/intent. /payment only works in a SPA if you let the browser do a native full-page form post to it. Either way the customer ends up on a full-page navigation to the gateway, which 3D Secure requires.

Fastify (default)

The default adapter. Requires fastify and @fastify/formbody as peers. The plugin registers formbody with a qs parser so nested item fields parse correctly:

import Fastify from 'fastify';
import { sispFastifyPlugin } from '@akira-io/sisp/fastify';

const app = Fastify();
await app.register(sispFastifyPlugin, {
  sisp,
  prefix: '/sisp',
  authorizeRefund: (request) => Boolean(request.headers['x-admin']),
});

Express

import express from 'express';
import { sispRoutes } from '@akira-io/sisp/express';

const app = express();
app.use('/sisp', sispRoutes(sisp, {
  authorizeRefund: (req) => req.user?.can('refund') ?? false,
}));

NestJS

Requires @nestjs/common and runs on the default Express platform:

import { Module } from '@nestjs/common';
import { SispModule } from '@akira-io/sisp/nest';

@Module({
  imports: [
    SispModule.forRoot({
      sisp,
      authorizeRefund: (req) => Boolean(req.headers['x-admin']),
    }),
  ],
})
export class AppModule {}

The module builds its controller from the basePath of the instance you pass it, so createSisp({ basePath: '/pay' }) serves POST /pay/payment and the signed retry, cancel, and callback URLs all resolve. It also exports the SISP token, so any provider can inject the instance:

import { Inject, Injectable } from '@nestjs/common';
import { SISP } from '@akira-io/sisp/nest';
import type { Sisp } from '@akira-io/sisp';

@Injectable()
export class BillingService {
  constructor(@Inject(SISP) private readonly sisp: Sisp) {}
}

setGlobalPrefix

Nest prefixes controller paths with app.setGlobalPrefix(...), and the package has no way to see it. Put the prefix in basePath and repeat it in globalPrefix so the module can subtract it from the controller path before Nest adds it back:

const sisp = await createSisp({ basePath: '/api/sisp', /* ... */ });

@Module({ imports: [SispModule.forRoot({ sisp, globalPrefix: 'api' })] })
export class AppModule {}

app.setGlobalPrefix('api');

The routes land on /api/sisp/*, which is what basePath promised the gateway. forRoot throws when basePath does not start with globalPrefix, rather than letting the gateway return to a 404. The same option exists on StatelessSispModule.forRoot.

Stateless adapters

Each framework also exports a stateless counterpart, taking a StatelessSisp (from createStatelessSisp) instead of a Sisp. See Stateless Mode for the route table and the security trade-offs.

import { statelessSispRoutes } from '@akira-io/sisp/express';
import { statelessSispFastifyPlugin } from '@akira-io/sisp/fastify';
import { StatelessSispModule } from '@akira-io/sisp/nest';

app.use('/sisp', statelessSispRoutes(statelessSisp));

await app.register(statelessSispFastifyPlugin, { sisp: statelessSisp, prefix: '/sisp' });

@Module({
  imports: [StatelessSispModule.forRoot({ sisp: statelessSisp })],
})
export class AppModule {}

Express and Fastify mount POST /payment and POST /payment/intent only when correlation is configured, and mount GET /callback only when appKey is configured; the stateful-only routes (refund, retry, cancel, transactions, transaction-status) 404 in every configuration, since they do not exist in stateless mode. POST /callback, GET /countries, and the sandbox routes are always mounted regardless of configuration. Nest cannot mount conditionally, so StatelessSispModule always declares every stateless route and relies on CorrelationRequiredError / a plain redirect at request time instead. Configure both correlation and appKey before mounting StatelessSispModule under Nest, or do not mount it.

Next: Security