Better Payment

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-3ds and callback
  • the card queries: installment and bin-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).

MethodPathActionProvider methodDefault
POST/:provider/payment/complete-3dspayment/complete-3dscompleteThreeDSPayment()
POST/:provider/callbackcallbackcompleteThreeDSPayment()
POST/:provider/installmentinstallmentinstallmentInfo()
POST/:provider/bin-checkbin-checkbinCheck()
POST/:provider/paymentpaymentcreatePayment()—
POST/:provider/payment/init-3dspayment/init-3dsinitThreeDSPayment()—
POST/paytr/payment/tokenpayment/tokencreatePaymentWithToken()—
GET/:provider/payment/:idpayment/getgetPayment()— 🔒
POST/:provider/refundrefundrefund()— 🔒
POST/:provider/cancelcancelcancel()— 🔒
POST/:provider/authorizeauthorizeauthorize()—
POST/:provider/authorize/init-3dsauthorize/init-3dsinitThreeDSAuthorize()—
POST/:provider/capturecapturecapture()— 🔒
POST/:provider/voidvoidvoidAuthorization()— 🔒
POST/:provider/cards/savecards/savesaveCard()— 🔒
POST/:provider/cards/listcards/listlistCards()— 🔒
POST/:provider/cards/deletecards/deletedeleteCard()— 🔒
POST/iyzico/checkout/initcheckout/initinitCheckoutForm()—
POST/iyzico/checkout/retrievecheckout/retrieveretrieveCheckoutForm()—
POST/iyzico/pwi/initpwi/initinitPWIPayment()—
POST/iyzico/pwi/retrievepwi/retrieveretrievePWIPayment()—
POST/iyzico/subscription/initializesubscription/initializeinitializeSubscription()—
POST/iyzico/subscription/{cancel,upgrade,retrieve,card-update}……— 🔒
POST/iyzico/subscription/{product,pricing-plan}……— 🔒
GET/health——

🔒 = needs authorize.

Responses

SituationHTTP statusBody
success / pending result200provider result
failure result422provider result (errorCode, errorMessage, rawResponse)
PayTR notification (/paytr/callback)200 text/plainOK (400 when the hash is invalid)
callbackRedirect returned a URL303Location header
Invalid input / wrong method / not enabled400 / 405 / 404{ error, message }
authorize rejected403{ error, message }
Unexpected error500{ 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_Pay finalization), runs onCallback, and stores the response.
  • An identical delivery gets the stored response (PayTR: OK, browser: the same redirect). onCallback does not run again, and the provider is not called again.
  • If onCallback throws, the handler answers 500 (PayTR retries). The next delivery re-runs onCallback with 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
FrameworkImportUsage
Next.js (App Router)better-payment/nextexport const { GET, POST } = toNextJsHandler(getBetterPayment)
Expressbetter-payment/expressapp.all('/api/pay/*path', toExpressHandler(payment))
Fastifybetter-payment/fastifyapp.register(toFastifyPlugin(payment), { prefix: '/api/pay' })
Honobetter-payment/honoapp.all('/api/pay/*', toHonoHandler(payment))
Elysiabetter-payment/elysiaapp.all('/api/pay/*', toElysiaHandler(payment), { parse: 'none' })
node:httpbetter-payment/expresscreateServer(toNodeHandler(payment))
Workers, Deno, Bunbetter-paymenttoFetchHandler(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.

On this page