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:
| Method | Route | Purpose |
|---|---|---|
| POST | /payment | Validate, persist, and render the gateway form as HTML (auto-submitting) |
| POST | /payment/intent | Same validate + persist, but return the gateway target as JSON for a SPA |
| GET, POST | /callback | Payment result page and gateway notification |
| GET, POST | /retry-payment | Signed retry flow |
| GET | /cancel | Signed cancel flow |
| GET, POST | /sandbox | Local fake gateway (sandbox mode only) |
| GET | /countries | ISO country catalog with numeric codes and flags |
| GET | /transactions/:ref | Transaction 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/:transaction | Refund, 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 /payment | POST /payment/intent | |
|---|---|---|
| Response | HTML auto-submitting form | JSON { action, fields, ref } |
| Redirect to the gateway | Automatic, the browser submits the returned form | Your frontend builds the form and submits it |
| Use it from | A 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 run | Yes |
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