HTTP Handler
Framework-agnostic, secure-by-default HTTP handler.
You are viewing the beta docs
npm install better-payment@beta. Go to the stable docsBetterPaymentHandler 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
exposeRawResponse: false, // default: responses leave out the provider's rawResponse
},
});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) |
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.
Raw provider responses
Added in 0.9.0Responses leave out the provider's rawResponse. It can hold merchant data (iyzico commission rates and fraud status) and stored-card keys (cardUserKey, cardToken) that the customer's browser should not see. onCallback, callbackRedirect and the events still receive the full result. Set exposeRawResponse: true to include it, for example on an admin-only handler:
const admin = payment.createHandler({ basePath: '/admin/pay', authorize: isAdmin, exposeRawResponse: true });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).
Scoping keys to the caller
Added in 0.9.0Keys are shared by all callers. When keys are predictable (such as refund-<orderId>) and transformRequest builds the body on the server, two users can send the same key and body and the second gets the first one's response. Return the caller from idempotency.scope so each user has their own keys:
handler: {
idempotency: { scope: async (ctx) => (await getSession(ctx.request))?.userId },
},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.
Only provider callbacks accept form bodies. Other actions answer 415 for
application/x-www-form-urlencoded, multipart/form-data and text/plain: a
browser form on another site can send these without a CORS preflight, together
with the user's cookies. Send JSON from your frontend (better-payment/client
does).
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' }) |
| React Router v7, Remix v2 | better-payment/react-router | export const { loader, action } = toReactRouterHandler(getBetterPayment) |
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.