BetterPaymentHandler
Framework-agnostic, secure-by-default HTTP handler.
BetterPaymentHandler maps HTTP requests to provider methods. It works with any
framework: you convert the framework's request into a BetterPaymentRequest
and send back the BetterPaymentResponse.
Secure defaults
With no options, the handler exposes only:
- the provider callbacks:
payment/complete-3dsandcallback - the card queries:
installmentandbin-check GET /health
Enable everything else explicitly with allowedActions. Refund, cancel, payment
lookup and subscription management also need an authorize hook; without
one, creating the handler throws.
const payment = new BetterPayment({
providers: { /* ... */ },
handler: {
basePath: '/api/pay', // default
allowedActions: ['payment/init-3ds', 'payment/complete-3ds', 'callback', 'refund', 'payment/get'],
authorize: async (ctx) => {
// callbacks come from the bank or the customer's browser and are signature-checked
if (ctx.action === 'callback' || ctx.action === 'payment/complete-3ds') return true;
if (ctx.action === 'payment/init-3ds') return !!(await getSession(ctx.request.headers.cookie));
return isAdmin(ctx.request.headers.authorization);
},
// Build the provider request on the server; never trust the browser's amounts
transformRequest: async (ctx) => {
if (ctx.action !== 'payment/init-3ds') return ctx.body;
const order = await db.orders.find(ctx.body.orderId);
return {
price: order.total,
paidPrice: order.total,
currency: 'TRY',
conversationId: order.id,
basketId: order.id,
callbackUrl: `${process.env.APP_URL}/api/pay/iyzico/payment/complete-3ds`,
paymentCard: ctx.body.paymentCard,
buyer: order.buyer,
shippingAddress: order.shippingAddress,
billingAddress: order.billingAddress,
basketItems: order.items,
};
},
// Called with verified callback results (3D return, PayTR notification)
onCallback: async (result, ctx) => {
await db.orders.updatePayment(result.paymentId, result.status);
},
// Redirect the customer after 3D Secure (303 See Other)
callbackRedirect: (result) => `/orders/${result.paymentId}?payment=${result.status}`,
exposeErrors: false, // default: 500 responses do not include internal error messages
},
});Use payment.createHandler(options) when you need more than one handler, for
example a public handler and an admin handler.
Routes
Paths are relative to basePath (default /api/pay).
| Method | Path | Action | Provider method | Default |
|---|---|---|---|---|
| POST | /:provider/payment/complete-3ds | payment/complete-3ds | completeThreeDSPayment() | ✅ |
| POST | /:provider/callback | callback | completeThreeDSPayment() | ✅ |
| POST | /:provider/installment | installment | installmentInfo() | ✅ |
| POST | /:provider/bin-check | bin-check | binCheck() | ✅ |
| POST | /:provider/payment | payment | createPayment() | — |
| POST | /:provider/payment/init-3ds | payment/init-3ds | initThreeDSPayment() | — |
| POST | /paytr/payment/token | payment/token | createPaymentWithToken() | — |
| GET | /:provider/payment/:id | payment/get | getPayment() | — 🔒 |
| POST | /:provider/refund | refund | refund() | — 🔒 |
| POST | /:provider/cancel | cancel | cancel() | — 🔒 |
| POST | /iyzico/checkout/init | checkout/init | initCheckoutForm() | — |
| POST | /iyzico/checkout/retrieve | checkout/retrieve | retrieveCheckoutForm() | — |
| POST | /iyzico/pwi/init | pwi/init | initPWIPayment() | — |
| POST | /iyzico/pwi/retrieve | pwi/retrieve | retrievePWIPayment() | — |
| POST | /iyzico/subscription/initialize | subscription/initialize | initializeSubscription() | — |
| POST | /iyzico/subscription/{cancel,upgrade,retrieve,card-update} | … | … | — 🔒 |
| POST | /iyzico/subscription/{product,pricing-plan} | … | … | — 🔒 |
| GET | /health | — | — | ✅ |
🔒 = needs authorize.
Responses
| Situation | HTTP status | Body |
|---|---|---|
success / pending result | 200 | provider result |
failure result | 422 | provider result (errorCode, errorMessage, rawResponse) |
PayTR notification (/paytr/callback) | 200 text/plain | OK (400 when the hash is invalid) |
callbackRedirect returned a URL | 303 | Location header |
| Invalid input / wrong method / not enabled | 400 / 405 / 404 | { error, message } |
authorize rejected | 403 | { error, message } |
| Unexpected error | 500 | { error, message: 'Internal server error' } |
PayTR re-sends a notification until it receives exactly OK. Return res.body as plain text when res.headers['Content-Type'] is text/plain; do not JSON-encode it. If onCallback throws, the handler returns 500 and PayTR retries the notification.
Interfaces
interface BetterPaymentRequest {
method: string;
url: string; // absolute or path-only
headers: Record<string, string>;
body?: any; // parsed object, or a raw string (JSON / x-www-form-urlencoded)
}
interface BetterPaymentResponse {
status: number;
headers: Record<string, string>;
body: any; // JSON object, or a string for text/plain responses
}
interface HandlerContext {
provider: ProviderType;
action: HandlerAction;
params: Record<string, string>; // { paymentId } for payment/get
request: BetterPaymentRequest;
body: any;
}Express
import express from 'express';
import { payment } from './lib/payment';
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.all('/api/pay/*', async (req, res) => {
const result = await payment.handler.handle({
method: req.method,
url: req.originalUrl,
headers: req.headers as Record<string, string>,
body: req.body,
});
res.status(result.status).set(result.headers);
if (typeof result.body === 'string') res.send(result.body);
else res.json(result.body);
});For Next.js, see the Next.js guide.