Payment Flow
Both flows run through pipelines of small single-purpose pipes, the same semantics as the Laravel package.
Payment handler and pipeline (POST /payment)
- Validation.
amountmust be at least 0.01,itemsmust be present, every line total must equal quantity times unit price in minor units, and the amount must equal the sum of line totals. Failures return HTTP 422 with Laravel-style error keys such asitems.0.total_price. - Duplicate guard. A body carrying a
merchantRefandmerchantSessionthat already exist is redirected instead of reprocessed. - PaymentContextResolver. Reads the configured idempotency key, reserves
sisp_payment_intents, and reuses the existing transaction when the same checkout is posted again. - EnsureIpIsNotBlacklisted. Rejects blacklisted IPs with HTTP 403.
- EnforceRateLimits. DB-backed fixed windows per IP, per merchant (
posID), and per customer (hashed email or phone), HTTP 429 when any enabled window is exceeded. - BuildPaymentRequest. Fills refs, session, and timestamp from the generators, signs the request fingerprint, and builds the base64
purchaseRequestwhenis3DSecis'1'(missing customer data throwsMissingThreeDSecureDataError). - PersistTransaction. Inside one DB transaction: pending transaction row, first transaction attempt, customer data, and items with prices in cents. Identifier collisions are retried with a new signed request.
- Invoice stub. Created after the core transaction write. Invoice failures never break the payment.
- CaptureRequestMetadata. IP, user agent, device type, browser, OS, device fingerprint, and a redacted copy of the request. Omitted from the pipeline when
security.collectMetadataisfalse.
The handler responds with an auto-submitting HTML form. Its action is the driver’s payment endpoint with FingerPrint, TimeStamp, and FingerPrintVersion repeated on the query string, exactly as SISP expects.
For a SPA that talks to the backend with fetch, post to /payment/intent instead. It runs the same pipeline but returns the gateway target as JSON { action, fields, ref }; the frontend then builds the form and submits it full-page. The HTML /payment route cannot be consumed over fetch (the returned markup will not navigate and its inline script will not run). See Adapters.
When the same idempotency key is posted again with the same body, the handler does not create a second transaction. It either renders the stored payment request, or creates a new attempt on the same failed transaction when retry is allowed. The same key posted with a different body (amount, items, customer fields) is refused with IdempotencyKeyReusedError (HTTP 409): a key is bound to the request that first reserved it and never hands out another customer’s payment request.
Callback pipeline (POST /callback)
A cancellation post, flagged userCancelled or UserCancelled (SISP’s own specification table and its PHP sample disagree on the casing, so both are read), resolves the transaction by merchantRef plus merchantSession, cancels it, and emits transaction:cancelled before redirecting to redirectUrl; unknown or already-terminal transactions just redirect. Payloads missing ref or session redirect to redirectUrl. Replays redirect without reprocessing, emitting callback:rejected with callback_replayed: the matching attempt is already completed, or it already recorded the same gateway transaction_id. A payload whose ref and session match nothing emits callback:rejected with unknown_transaction and redirects. A failed attempt that receives a callback for a different gateway transaction is processed normally. Then:
- ResolveTransaction by
merchantRefplusmerchantSessionagainstsisp_transaction_attempts, falling back tosisp_transactionsfor rows that predate attempts. Nothing is written here. - ValidateFingerprint. Constant-time comparison of the callback fingerprint, over the 16 fields of section 2.4.2.1 for a success response and the 10 fields of section 2.4.2.2 for an error response (
messageType6). A mismatch short-circuits withinvalid_callback_fingerprintand emitscallback:rejected; nothing is written, the transaction keeps its status, and the HTTP handler redirects toredirectUrl. An unsigned POST can never fail a pending transaction or block the genuine callback that follows it. - EnsureCallbackMatchesTransaction. Ref and session must match the stored transaction, otherwise
callback_details_mismatch. Amount (compared in thousandths), currency, transaction code and posID are checked only when the callback sends them; an error response sends none of them. - ApplyTransactionStatus. Updates the attempt and, when propagation is allowed, the parent transaction inside one database transaction. Legacy rows get their attempt backfilled here, under the row lock and only after the fingerprint verified. Message types
8,P,M,A,B,C,10and?complete the payment;6fails it; anything else stays pending. - DispatchPaymentEvents. Emits
payment:completed,payment:failed, orpayment:pending.
After the pipeline the handler stores request metadata and updates the invoice status. Both run quietly: nothing after completion may break the callback response.
Result page (GET /callback?transaction=&expires=&signature=)
The callback redirects the browser to a signed result URL that expires 30 minutes after it is issued; URLs without an expiry are refused. Returns render-ready JSON: transaction summary with formatted_amount, structured error data (code, category, suggested action, labels translated to the transaction locale), invoice summary, and a signed retryUrl when retry is available. Adapters or frontends decide how to render it. Resolving retry availability and reading the current attempt are best effort: when either query fails, the route still answers with the transaction summary, reports the failure through onSideEffectError and omits the retry link rather than answering an error.
Next: Transaction Management