node-sispnode-sisp
Beta

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

Configuration

createSisp(config) accepts a single object. Only posId, posAutCode, and database are required; every other key mirrors config/sisp.php from the Laravel package and keeps the same default.

Credentials and gateway

KeyDefaultDescription
posIdrequiredVirtual POS terminal id issued by SISP
posAutCoderequiredVirtual POS terminal password, source of every fingerprint
url''Gateway payment URL used by the production driver
currency'132'ISO 4217 numeric code, Cabo Verde Escudo
languageMessages'EN'Language for gateway response messages
fingerprintVersion'1'Payment request fingerprint version
is3DSec'0'Set '1' to require 3D Secure customer data
transactionCode'1'Default transaction type (purchase)

SISP issues two different numbers, and this package wants only one of them. posId is the Virtual POS terminal. The merchant id is a separate number that never leaves your records: it is not part of the payment payload and not part of any fingerprint, so there is no config key for it.

Passing the merchant id as posId is the easiest mistake to make here, and the symptom does not point at it. posId is hashed into every payment request fingerprint, so the gateway rejects each attempt before it ever reaches the card form, answering with messageType 6 and Fingerprint Invalid. The error names the fingerprint, not the field, so it reads like a broken signature rather than a swapped value.

Application wiring

KeyDefaultDescription
databaserequired{ client, connection, autoMigrate } passed to knex. connection is typed loosely (string | object | (() => object | Promise<object>)) on the main entry so consumers do not need knex installed to typecheck; import SispKnexDatabaseConfig from @akira-io/sisp/knex for the fully-typed knex connection shapes, including knex’s connection-provider function form for rotating credentials
appKeynullKey for payload encryption (AES-256-GCM) and signed URLs. Required by createSisp to persist payloads; at least 32 characters outside sandbox mode
previousAppKeys[]Keys that used to be appKey, kept only so rows they encrypted stay readable during a rotation. Cannot be combined with a caller-provided storage; see below
baseUrl''Absolute origin used when building route URLs
basePath'/sisp'Mount path of the HTTP routes. Normalized to a leading slash and no trailing slash, so pay, /pay and /pay/ all resolve to /pay
urlMerchantResponsecallback routeWhere SISP posts the payment result
redirectUrl'/'Fallback redirect for cancelled or unknown callbacks
frontendResultUrlnullWhen set, a processed callback redirects the browser to ${frontendResultUrl}?ref=… instead of the JSON result page, handing control to a SPA
driverderived'production', 'sandbox', or a custom driver name
sandboxfalseSelects the sandbox driver when no explicit driver. Refused when NODE_ENV is production unless allowSandboxInProduction is true
allowSandboxInProductionfalseOpt-in to keep the public /sandbox route with NODE_ENV=production
allowWeakAppKeyfalseAccept an appKey shorter than 32 characters outside sandbox mode, for installations that still have to rotate
idempotency.excludeFromHash_token, _csrf, _method, csrf_token, authenticity_tokenBody fields left out of the idempotency request hash, on top of idempotency.requestKeys
allowRetrytrueEnables the retry flow for failed payments
tablessisp_*Override any of the package table names

previousAppKeys with your own storage

createSisp throws when previousAppKeys is set together with a caller-provided storage, because the adapter you built already owns its cipher and the package cannot reach into it:

`previousAppKeys` cannot be honoured with a caller-provided `storage`: pass { current, previous } as the app key to createDrizzleStorage, createPrismaStorage or KnexStorage.create instead.

On Prisma, Drizzle or an injected knex storage, leave previousAppKeys out of the createSisp config and pass the keys to the adapter factory:

const storage = createPrismaStorage(
  prisma,
  DEFAULT_TABLES,
  { current: process.env.APP_KEY, previous: [process.env.APP_KEY_PREVIOUS] },
  { provider: 'postgresql' },
);

The rotation procedure in Security is otherwise the same.

Guards

rateLimiting: {
  enabled: true,
  perIp: { enabled: true, limit: 100, windowSeconds: 3600 },
  perIpStatus: { enabled: true, limit: 3600, windowSeconds: 3600 },
  perMerchant: { enabled: false, limit: 500, windowSeconds: 3600 },
  perUser: { enabled: true, limit: 50, windowSeconds: 3600 },
},
security: {
  collectMetadata: true,
  clientIp: (request) => headerValue(request, 'x-real-ip'),
  metadataRetentionDays: null,
},

rateLimiting guards the payment pipeline with three fixed windows, checked in order: perIp, then perMerchant, then perUser. The first window that is exceeded raises HTTP 429 and the remaining windows record no hit for that request, so the scopes are ordered, not independent. A rule with enabled: false is skipped entirely, and enabled: false at the top level turns off all three. The refund route applies only perIp, and the transaction-status route only perIpStatus, each on its own bucket.

perIp keys on the resolved client IP. perMerchant keys on the posId of the Sisp instance handling the request, so it caps that merchant regardless of how many addresses the traffic arrives from; it is off by default, because exceeding it blocks every payment for the merchant until the window ends. perUser keys on an HMAC-SHA-256 of the customer email, using appKey as the key, and falls back to the customer phone when no email is present; the value is trimmed and lowercased before hashing, and the scope is skipped when the request carries neither field. A customer who sends an email on one request and only a phone on the next occupies two buckets.

perIpStatus covers GET /transactions/:ref, which a checkout page polls while it waits for the gateway. It keys on the same resolved client IP as perIp but counts into its own bucket, so polling never exhausts the payment budget: at the default of 3600 per hour, one request per second stays inside it, and behind a NAT address every client on it no longer spends from the payment allowance. Lower the limit when the checkout polls slowly, raise windowSeconds to spread the same budget over longer sessions, or set perIpStatus.enabled to false to leave status lookups unlimited.

security.collectMetadata set to false drops CaptureRequestMetadata from the payment pipeline and stops the callback handler from writing to sisp_request_metadata, so no IP, user agent, header, or device-fingerprint row is created. Leave it true unless a data-protection requirement says otherwise; the reconciliation and audit trails do not depend on it.

security.metadataRetentionDays defaults to null, meaning nothing purges sisp_request_metadata automatically. Set it to the number of days to keep, then run it with the sisp prune-metadata command or sisp.pruneRequestMetadata(). See Security.

security.clientIp resolves the address used for per-IP rate limits, the IP blacklist, and request metadata. Without it the package uses the adapter’s req.ip, which behind a reverse proxy is the proxy’s address unless the framework is told to trust it (app.set('trust proxy', ...) in Express, trustProxy in Fastify). When the resolver returns null or an empty string the package falls back to the adapter’s req.ip; per-IP limits and blacklist checks are skipped only when that is empty too, instead of sharing one bucket.

Reconciliation

transactionStatus: {
  url: 'https://comerciante.vinti4.cv/pos/transaction-status',
  portalId: '',
  portalPassword: '',
  timeoutSeconds: 10,
  reconciliationEnabled: false,
  reconcileAfterMinutes: 5,
  reconcileLimit: 50,
}

Idempotency

idempotency: {
  enabled: true,
  requestKeys: ['idempotency_key', 'checkout_intent_id'],
}

The payment handler reads the first non-empty configured key from the request body. That key is stored in sisp_payment_intents and linked to the local transaction. Reposting the same checkout key reuses the same transaction instead of creating a duplicate.

Use one stable key per checkout intent. Do not use a timestamp as the idempotency key, because a new timestamp is generated on every click.

Identifier generation

identifierGeneration: {
  maxAttempts: 5,
  collisionRetrySleepMs: 1000,
}

merchantReference, merchantSession, and retry attempts are protected by unique constraints. If a custom generator collides, the package retries with a new candidate until maxAttempts is reached. The default sleep is 1000 milliseconds, which is one second.

SISP caps merchantRef and merchantSession at 15 characters. Longer values are truncated by the gateway before it recomputes the request fingerprint, so the payment is rejected with messageType=6 / Fingerprint Invalid and the card page never loads. The built-in generators stay within 15 characters; keep any custom generator within the limit too.

Extension points

KeyDescription
generatorsReplace merchantReference, merchantSession, or timeStamp factories
pipelines.payment(defaults) => pipes to reorder, remove, or add payment pipes
pipelines.callbackSame for the callback pipeline
onEventListenerErrorReceives errors thrown by event listeners, and nothing else
onSideEffectErrorStateful mode only. Receives errors from the audit side effects the package swallows: create_invoice_stub, store_request_metadata, update_invoice_status, resolve_retry_availability, load_current_attempt, cancel_user_cancelled_transaction. Stateless mode performs none of them, so it never fires
const sisp = await createSisp({
  posId: process.env.SISP_POS_ID,
  posAutCode: process.env.SISP_POS_AUT_CODE,
  url: process.env.SISP_URL,
  appKey: process.env.APP_KEY,
  baseUrl: 'https://app.example.cv',
  database: { client: 'pg', connection: process.env.DATABASE_URL },
  generators: {
    merchantReference: () => `R${Date.now()}`,
  },
});

Custom generators may keep using date-based values. The package does not require a specific format, but identifiers must stay within SISP’s 15-character limit and pass the database uniqueness checks within the configured retry limit.

No database at all

createSisp still requires either storage or database; that check has not changed. If you already own transaction tables and want the gateway protocol handled without the package persisting anything of its own, use createStatelessSisp instead. See Stateless Mode.

Next: Quick Start