better-payment
API Reference

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-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 = 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).

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/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.

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.

On this page