HTTP Handler
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 = 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 | /:provider/authorize | authorize | authorize() | — |
| POST | /:provider/authorize/init-3ds | authorize/init-3ds | initThreeDSAuthorize() | — |
| POST | /:provider/capture | capture | capture() | — 🔒 |
| POST | /:provider/void | void | voidAuthorization() | — 🔒 |
| POST | /:provider/cards/save | cards/save | saveCard() | — 🔒 |
| POST | /:provider/cards/list | cards/list | listCards() | — 🔒 |
| POST | /:provider/cards/delete | cards/delete | deleteCard() | — 🔒 |
| 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.
Duplicate callbacks and Idempotency-Key
Providers deliver callbacks more than once: PayTR resends a notification until it gets OK, and customers reload or go back to the 3D Secure return page. The handler processes each callback once:
- The first delivery calls the provider (verification, and for Parampos the
TP_WMD_Payfinalization), runsonCallback, and stores the response. - An identical delivery gets the stored response (PayTR:
OK, browser: the same redirect).onCallbackdoes not run again, and the provider is not called again. - If
onCallbackthrows, the handler answers 500 (PayTR retries). The next delivery re-runsonCallbackwith the stored provider result, without calling the provider again. - A duplicate that arrives while the first is still running gets
409, and the provider retries later. - Forged callbacks (
INVALID_HASH) and unknown outcomes (NETWORK_ERROR) are never stored.
Duplicates are recognized by a SHA-256 hash of the callback body. A forged body can never match a stored response.
Mutating routes (payment, payment/init-3ds, refund, cancel, authorize, capture, void, cards/save, cards/delete, iyzico checkout/PWI/subscription management) also honor an Idempotency-Key header. Send a unique key per operation, for example a UUID or refund-<orderId>:
- The same key and the same body return the first response, marked
Idempotent-Replayed: true, without calling the provider again. - The same key with a different body returns
422. - A request that fails before a result (validation error, unexpected error) releases its key.
Results are remembered for 24 hours by default. The default store is in-memory, so it only deduplicates within one process. With several instances or serverless functions, pass a shared store:
import Redis from 'ioredis';
import type { IdempotencyStore } from 'better-payment';
const redis = new Redis(process.env.REDIS_URL!);
const redisStore: IdempotencyStore = {
get: async (key) => (await redis.get(key)) ?? undefined,
set: async (key, value, ttl) => void (await redis.set(key, value, 'EX', ttl)),
setIfAbsent: async (key, value, ttl) => (await redis.set(key, value, 'EX', ttl, 'NX')) === 'OK',
delete: async (key) => void (await redis.del(key)),
};
betterPayment({
providers: { ... },
handler: {
idempotency: { store: redisStore, ttlSeconds: 7 * 24 * 3600 }, // or `false` to turn it off
},
});setIfAbsent must be atomic: Redis SET ... NX, or an insert into a table with a unique key in SQL (INSERT ... ON CONFLICT DO NOTHING and check the affected rows).
Interfaces
interface BetterPaymentRequest {
method: string;
url: string; // absolute or path-only
headers: Record<string, string>;
body?: unknown; // parsed object, or a raw string (JSON / x-www-form-urlencoded)
}
interface BetterPaymentResponse {
status: number;
headers: Record<string, string>;
body: unknown; // JSON object, or a string for text/plain responses
}
interface HandlerContext {
provider: string | undefined; // the provider id; undefined for plugin endpoints
action: HandlerAction; // 'payment/init-3ds', ... or 'plugin:<plugin id>/<endpoint name>'
params: Record<string, string>; // { paymentId } for payment/get
request: BetterPaymentRequest;
body: Record<string, unknown> | undefined; // undefined when there is no object body
}ctx.body comes from the client, so its fields are typed unknown: validate them
(or cast after validating) before you use them. onCallback and callbackRedirect
receive a PaymentResponse.
Plugins can add routes to the handler (plugin endpoints). The authorize hook runs for them too, with provider: undefined and action: 'plugin:<plugin id>/<endpoint name>'. For callbacks, payment events are emitted right before onCallback, and are retried with it when a listener fails.
Frameworks
Adapters connect the handler to your framework and handle the details that are easy to get wrong:
- raw bodies for form-urlencoded bank callbacks
- PayTR's plain-text
OK - redirects with no body
| Framework | Import | Usage |
|---|---|---|
| Next.js (App Router) | better-payment/next | export const { GET, POST } = toNextJsHandler(getBetterPayment) |
| Express | better-payment/express | app.all('/api/pay/*path', toExpressHandler(payment)) |
| Fastify | better-payment/fastify | app.register(toFastifyPlugin(payment), { prefix: '/api/pay' }) |
| Hono | better-payment/hono | app.all('/api/pay/*', toHonoHandler(payment)) |
| Elysia | better-payment/elysia | app.all('/api/pay/*', toElysiaHandler(payment), { parse: 'none' }) |
node:http | better-payment/express | createServer(toNodeHandler(payment)) |
| Workers, Deno, Bun | better-payment | toFetchHandler(payment) |
See Next.js, other frameworks and edge runtimes. For any other framework, convert its request into a BetterPaymentRequest (pass the body as raw text), call payment.handler.handle() and write the BetterPaymentResponse back.