Unified, type-safe payment gateway for Node.js & TypeScript.
One API for Turkish payment providers — iyzico, PayTR, Parampos, Sipay, Akbank, Kuveyt Türk and the EST banks (İş Bankası, Ziraat, Halkbank, TEB, Şekerbank).
0.0.1 is a fresh start. The version history was reset on purpose. Releases
1.x–3.xare deprecated. See What's new in 0.0.1.
0.0.1 is not a patch on top of 3.x. It is the first release of a rebuilt
library. A review found that the old releases had provider integrations that
did not match the providers' APIs, and callback checks that could be bypassed
(for example, forging a Parampos "success" callback). Fixing that required
breaking changes. Rather than stacking another major version on top of those
releases, we reset the version history. 0.0.1 is the new baseline.
What changed:
- Callbacks are verified, not trusted. Every 3D Secure callback and PayTR notification is checked with your own credentials. Parampos payments are finalized with the bank before they count as successful.
- Real provider APIs. PayTR signing, Parampos
TP_WMD_*, the Akbank Sanal POS JSON API and iyzico payment statuses now match the official specifications. - Clear statuses.
success,failure,pendingandcancelled. A timeout ispendingwithNETWORK_ERROR(the outcome is unknown), never a silent failure. Payments are never retried automatically. - Your order id is the payment id for PayTR, Parampos and Akbank.
- Secure-by-default HTTP handler: routes are opt-in, sensitive actions require
authorize, and amounts can be set on the server. - One config per provider, validated at startup.
Upgrading from 3.x: npm install better-payment@latest (a ^3 range will never
resolve to 0.0.1), then follow the
migration guide.
npm install better-payment
# or
pnpm add better-paymentNo runtime dependencies. Runs on Node.js 20+ and on edge runtimes (Vercel Edge,
Cloudflare Workers, Deno, Bun): it only uses fetch and WebCrypto. See the
edge runtimes guide.
better-payment/client is browser-safe.
import { betterPayment, iyzico, paytr, parampos, akbank } from 'better-payment';
const payment = betterPayment({
mode: 'sandbox', // sandbox URLs + provider test modes
providers: {
iyzico: iyzico({
apiKey: process.env.IYZICO_API_KEY!,
secretKey: process.env.IYZICO_SECRET_KEY!,
}),
paytr: paytr({
merchantId: process.env.PAYTR_MERCHANT_ID!,
merchantKey: process.env.PAYTR_MERCHANT_KEY!,
merchantSalt: process.env.PAYTR_MERCHANT_SALT!,
}),
parampos: parampos({
clientCode: process.env.PARAMPOS_CLIENT_CODE!,
clientUsername: process.env.PARAMPOS_CLIENT_USERNAME!,
clientPassword: process.env.PARAMPOS_CLIENT_PASSWORD!,
guid: process.env.PARAMPOS_GUID!,
}),
akbank: akbank({
merchantSafeId: process.env.AKBANK_MERCHANT_SAFE_ID!,
terminalSafeId: process.env.AKBANK_TERMINAL_SAFE_ID!,
secretKey: process.env.AKBANK_SECRET_KEY!,
}),
},
defaultProvider: 'iyzico',
});
// Start a 3D Secure payment and render result.threeDSHtmlContent in the browser
const result = await payment.iyzico.initThreeDSPayment({
price: '100.00',
paidPrice: '100.00',
currency: 'TRY',
basketId: 'B1',
callbackUrl: 'https://yoursite.com/api/pay/iyzico/payment/complete-3ds',
paymentCard: { ... },
buyer: { ... },
shippingAddress: { ... },
billingAddress: { ... },
basketItems: [ ... ],
});Every operation resolves to a result with status:
| status | meaning |
|---|---|
success |
The provider confirmed the operation (payment captured, refund accepted, ...) |
failure |
The provider rejected it; see errorCode / errorMessage / rawResponse |
pending |
Waiting for the customer (3DS, bank transfer) or the outcome is unknown |
cancelled |
The payment was voided or fully refunded (status queries) |
When a request times out or the connection drops, the result is pending with
errorCode: 'NETWORK_ERROR'. The provider may still have processed the
payment. Check it with getPayment() before you retry. Payment, refund and cancel
requests are never retried automatically.
Failed results also carry code, a provider-independent PaymentErrorCode
(INSUFFICIENT_FUNDS, CARD_DECLINED, THREEDS_FAILED, INVALID_HASH, ...).
The provider's raw code stays in errorCode. Unmapped codes are UNKNOWN. See
Error Codes for the
list and suggested customer messages.
if (result.code === PaymentErrorCode.INSUFFICIENT_FUNDS) askForAnotherCard();For PayTR, Parampos, Sipay, Akbank, Kuveyt Türk and EST, paymentId is your order id: the
conversationId you pass in, or an alphanumeric id generated for you. Pass
this id to refund, cancel and getPayment.
| Provider | Non-3D | 3D Secure | Refund | Cancel | Status | BIN | Installments |
|---|---|---|---|---|---|---|---|
| iyzico | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| PayTR | ✓ (needs non-3D approval) | ✓ iFrame | ✓ | ✓ (full refund) | ✓ | ✓ | ✓ (account rates) |
| Parampos | ✓ (TRY) | ✓ TP_WMD_UCD/TP_WMD_Pay |
✓ | ✓ | ✓ | ✓ | ✓ (account rates) |
| Sipay | ✓ (needs non-3D approval) | ✓ paySmart3D | ✓ | ✓ (refund) | ✓ | ✓ | ✓ (account rates) |
| Akbank | ✓ | ✓ 3D_PAY | ✓ | ✓ | ✓ | — | — |
| Kuveyt Türk | — | ✓ KT Pay Gate | ✓ | ✓ (same day) | ✓ | — | — |
| EST ¹ | ✓ | ✓ 3D / 3D Pay / 3D Host | ✓ | ✓ | ✓ | — | — |
¹ İş Bankası, Ziraat Bankası, Halkbank, TEB and Şekerbank, with est({ bank }).
- iyzico / Parampos / Akbank: the bank POSTs to your
callbackUrl. Pass the POST body tocompleteThreeDSPayment(). The signature is verified with your credentials before anything is trusted. Parampos then finalizes the payment withTP_WMD_Pay. - Kuveyt Türk: the bank POSTs to your
callbackUrltoo, but the POST is not signed.completeThreeDSPayment()looks the order up at the bank, checks the bank's order id and charges the card withProvision; the payment issuccessonly whenProvisionsucceeds. - Sipay: Sipay POSTs to your
callbackUrl(orfailUrl). The callback'shash_keyis decrypted with your app secret; its signed status, invoice id and order id decide the result, and the other fields are not trusted. - EST: the bank POSTs to your
callbackUrl. The hash (ver3) is verified, then the result is confirmed with the bank:3dand3d_hostonly authenticate the card and charge it with the XML API,3d_pay/3d_pay_hostingare checked with an order status query. - PayTR:
callbackUrlis only where the customer's browser returns. The result is sent to the notification URL set in the PayTR panel. Point it at/api/pay/paytr/callback; the handler verifies the notification and repliesOK.
Pre-authorization (block now, charge later) works on iyzico, Parampos, Sipay and Akbank:
authorize() / initThreeDSAuthorize(), then capture({ paymentId, amount, ip }) or
voidAuthorization({ paymentId, ip }). See the
pre-authorization guide.
Stored cards (iyzico, PayTR): pass saveCard: true with a payment, then pay with
storedCard: { customerToken, cardToken } instead of paymentCard. listCards() and
deleteCard() manage saved cards. Only tokens are stored, never card numbers. See the
stored cards guide.
Installments: pass installment in the request. For Parampos, set paidPrice
to the total including commission: payment.parampos.calculatePaidPrice({ binNumber, price, installment }) computes it from your Param rates. Akbank has no
installment-rate API; use your contracted rates. The library never invents
commission rates.
A framework-agnostic handler that exposes REST endpoints under /api/pay. It is
secure by default: out of the box it exposes only the provider callbacks and
the card queries (installment, bin-check).
const payment = betterPayment({
providers: { ... },
handler: {
// opt in to what your frontend needs
allowedActions: ['payment/init-3ds', 'payment/complete-3ds', 'callback', 'refund'],
// required for refund/cancel/payment lookup/subscription management
authorize: async (ctx) => {
if (ctx.action === 'callback' || ctx.action === 'payment/complete-3ds') return true;
return isAdmin(ctx.request.headers.authorization);
},
// never trust amounts from the browser
transformRequest: async (ctx) => {
if (ctx.action !== 'payment/init-3ds') return ctx.body;
const order = await db.orders.find(ctx.body?.orderId);
return buildPaymentRequest(order, ctx.body?.paymentCard);
},
// update your order from verified callbacks
onCallback: async (result) => {
await db.orders.markPaid(result.paymentId, result.status);
},
// send the customer somewhere after 3D Secure
callbackRedirect: (result) => `/orders/${result.paymentId}?status=${result.status}`,
},
});Mount it with an adapter. Each adapter handles raw form callbacks, PayTR's plain OK and redirects:
// Next.js — app/api/pay/[...path]/route.ts
import { toNextJsHandler } from 'better-payment/next';
export const { GET, POST } = toNextJsHandler(getBetterPayment);
// Express: app.all('/api/pay/*path', toExpressHandler(payment)) — better-payment/express
// Fastify: app.register(toFastifyPlugin(payment), { prefix: '/api/pay' }) — better-payment/fastify
// Hono: app.all('/api/pay/*', toHonoHandler(payment)) — better-payment/hono
// Elysia: app.all('/api/pay/*', toElysiaHandler(payment), { parse: 'none' }) — better-payment/elysia
// React Router / Remix: export const { loader, action } = toReactRouterHandler(getBetterPayment) — better-payment/react-router
// Workers, Deno, Bun: toFetchHandler(payment) — better-paymentRoute (under basePath) |
Action | Enabled by default |
|---|---|---|
POST /:provider/payment/complete-3ds |
payment/complete-3ds |
✓ |
POST /:provider/callback |
callback |
✓ (PayTR replies OK) |
POST /:provider/installment |
installment |
✓ |
POST /:provider/bin-check |
bin-check |
✓ |
POST /:provider/payment |
payment |
— |
POST /:provider/payment/init-3ds |
payment/init-3ds |
— |
POST /:provider/payment/token |
payment/token (PayTR) |
— |
GET /:provider/payment/:id |
payment/get |
— (needs authorize) |
POST /:provider/refund, /cancel |
refund, cancel |
— (needs authorize) |
POST /:provider/authorize[/init-3ds] |
authorize, authorize/init-3ds |
— |
POST /:provider/capture, /void |
capture, void |
— (needs authorize) |
POST /:provider/cards/{save,list,delete} |
cards/* |
— (needs authorize) |
POST /iyzico/checkout/*, /pwi/* |
iyzico only | — |
POST /iyzico/subscription/* |
iyzico only | — (management actions need authorize) |
GET /health |
health check | ✓ |
Failed operations return HTTP 422 with the result body. Unexpected errors return a
generic 500 unless you set exposeErrors: true.
Plugins add behaviour without changing the library: react to payment events,
route payments, add methods and routes. A plugin is an object with an id and
only the parts it needs:
import { betterPayment, definePlugin } from 'better-payment';
const auditLog = definePlugin({
id: 'audit-log',
events: {
'*': (event) => console.log(event.type, event.provider, event.paymentId),
},
});
const payment = betterPayment({ providers: { ... }, plugins: [auditLog] });
// Typed payment events, for every provider and flow, after verification
payment.on('payment.succeeded', (event) => orders.markPaid(event.conversationId));Official plugins ship under better-payment/plugins. localizedErrors puts
customer-facing error messages in errorMessage, in the customer's language
(English, Turkish, German, Russian, and Arabic included; per request from Accept-Language in the handler):
import { localizedErrors } from 'better-payment/plugins';
const payment = betterPayment({ providers: { ... }, plugins: [localizedErrors({ locale: 'tr' })] });chaos injects declines, lost responses (NETWORK_ERROR) and delays in tests, so you can check how your application handles them.
notifications sends sales, refunds and failures to Slack, Discord, Telegram or your own channel, in the background.
See Plugins, payment events and writing a plugin. Upgrading from 0.4? See Migrating to 0.5.
better-payment/testing has an in-memory MockProvider. It needs no credentials and no network access, and uses magic card numbers for declines, 3D Secure failures and lost responses. Its 3D Secure callbacks are signed and go through the real handler.
import { MockProvider, MOCK_CARDS } from 'better-payment/testing';
const mock = new MockProvider();
const payment = betterPayment({ providers: { mock } });
await payment.mock.createPayment({ ...order, paymentCard: { ...card, cardNumber: MOCK_CARDS.INSUFFICIENT_FUNDS } });
// → { status: 'failure', code: 'INSUFFICIENT_FUNDS' }See the testing guide.
const payment = betterPayment({
logger: {
debug: (msg, meta) => console.debug(msg, meta),
info: (msg, meta) => console.info(msg, meta),
error: (msg, err, meta) => console.error(msg, err, meta),
},
// Only idempotent requests (status/BIN/installment queries) are retried
retry: { attempts: 3, delay: 1000, statusCodes: [429, 503] },
providers: { ... },
});The logger receives method, URL and status only. Request bodies (card data, credentials) are never logged.
https://better-payment.czaylabs.com
Contributions are welcome, from a typo fix to a new provider. Start with CONTRIBUTING.md (Türkçe) and the good first issues. Report security problems privately, as described in SECURITY.md.
MIT
packages/better-payment— the npm packageapps/web— documentation site
pnpm install
pnpm test # package tests
pnpm lint
pnpm typecheck
pnpm build