You are viewing the beta docs.Go to the stable docs
Better Payment

HTTP Handler

Framework-agnostic, secure-by-default HTTP handler.

You are viewing the beta docs

Install the beta with npm install better-payment@beta. Go to the stable docs

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

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

Raw provider responses

Added in 0.9.0

Responses 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_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).

Scoping keys to the caller

Added in 0.9.0

Keys 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
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' })
React Router v7, Remix v2better-payment/react-routerexport const { loader, action } = toReactRouterHandler(getBetterPayment)
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