AI Decision System
Replayable, offline-verifiable records for AI-assisted go/no-go decisions: experiment promotions, typed model answers and human-approved actions.
翻譯準備中
一個統一且類型安全的 Node.js 與邊緣執行環境的支付閘道函式庫。使用單一一致的 API 並驗證回呼,整合 iyzico、PayTR、Parampos 與 Akbank。
AI 翻譯
A unified, type-safe payment gateway library for Node.js and edge runtimes. Integrate iyzico, PayTR, Parampos and Akbank with a single consistent API and verified callbacks.
開發工具TypeScript · MDXGitHub @czaydev上線中營運者未確認
試用過了嗎? 點一下就好,不需要登入。
統一且類型安全的 Node.js 與 TypeScript 支付閘道:一個 API 覆蓋 iyzico、PayTR、Parampos 和 Akbank。
AI 翻譯
Unified, type-safe payment gateway for Node.js & TypeScript: one API for iyzico, PayTR, Parampos and Akbank.
取自儲存庫 README
正在載入完整 README。 以下為已儲存的摘錄。
Unified, type-safe payment gateway for Node.js & TypeScript.
One API for Turkish payment providers — iyzico, PayTR, Parampos and Akbank.
0.0.1 is a fresh start. The version history was reset on purpose.
Releases 1.x–3.x are deprecated. See What's new in 0.0.1.檢視完整 README ↗One type-safe API for Turkish payments, in Node.js and on the edge.
Accept cards through iyzico, PayTR, Parampos, Sipay, Akbank, Kuveyt Türk, Garanti BBVA, Yapı Kredi, QNB, VakıfBank, Albaraka Türk, Denizbank and the EST banks (İş Bankası, Ziraat, Halkbank, TEB, Şekerbank) with one config, one result shape and verified 3D Secure callbacks. Switch or add a provider without rewriting your checkout.
initThreeDSPayment, refund, cancel and getPayment calls for every provider.success, failure, pending or cancelled, plus a provider-independent error code.Documentation · Quick start · Providers · Upgrading from 3.x
npm install better-payment
# or
pnpm add better-payment
No 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, Garanti BBVA, EST, QNB, VakıfBank and Denizbank, 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 / Direct API | ✓ | ✓ (full refund) | ✓ | ✓ | ✓ (account rates) |
| Parampos | ✓ (TRY) | ✓ TP_WMD_UCD/TP_WMD_Pay | ✓ | ✓ | ✓ | ✓ | ✓ (account rates) |
| Sipay | ✓ (needs non-3D approval) | ✓ paySmart3D | ✓ | ✓ (refund) | ✓ | ✓ | ✓ (account rates) |
| QNBpay ³ | ✓ (needs non-3D approval) | ✓ paySmart3D | ✓ | ✓ (refund) | ✓ | ✓ | ✓ (account rates) |
| Akbank | ✓ | ✓ 3D_PAY | ✓ | ✓ | ✓ | — | — |
| Kuveyt Türk | — | ✓ KT Pay Gate | ✓ | ✓ (same day) | ✓ | — | — |
| Garanti BBVA | ✓ | ✓ 3D / 3D_PAY / GarantiPay | ✓ | ✓ (same day) | ✓ | — | — |
| EST ¹ | ✓ | ✓ 3D / 3D Pay / 3D Host | ✓ | ✓ | ✓ | — | — |
| Denizbank | ✓ | ✓ 3D Model | ✓ | ✓ (same day) | — | — | — |
¹ İş Bankası, Ziraat Bankası, Halkbank, TEB, Şekerbank, Akbank's older virtual POS and
Halkbank Macedonia, with est({ bank }).
² Albaraka Türk. paymentId is the bank's ReferenceCode; the 3D completion needs the order id, amount and currency from your records. getPayment() takes the order id.
³ QNBpay runs on Sipay's platform: qnbpay() or sipay({ brand: 'qnbpay' }). Not verified with a test merchant yet.
callbackUrl. Pass the
POST body to completeThreeDSPayment(). The signature is verified with your
credentials before anything is trusted. Parampos then finalizes the payment
with TP_WMD_Pay.callbackUrl too, but the POST is not
signed. completeThreeDSPayment() looks the order up at the bank, checks the
bank's order id and charges the card with Provision; the payment is success
only when Provision succeeds.callbackUrl (or failUrl). The callback's
hash_key is decrypted with your app secret; its signed status, invoice id and
order id decide the result, and the other fields are not trusted.callbackUrl. The hash (ver3) is verified, then the
result is confirmed with the bank: 3d and 3d_host only authenticate the card and
charge it with the XML API, 3d_pay / 3d_pay_hosting are checked with an order
status query.callbackUrl. The HASH is rebuilt from the
fields in HASHPARAMS, then the card is charged with the callback's 3D Secure data
and the bank's answer decides.callbackUrl, unsigned. Call
completeThreeDSPayment({ ...callbackBody, orderId, amount, currency }) with the order
from your records: the card is charged with the 3D data and the bank's answer decides.callbackUrl, unsigned. The payment
is authorized on VPOS with MpiTransactionId only, so VPOS takes the card and amount
from its own 3D Secure record; callback values are never used for the charge.callbackUrl. Complete them in
your own route with the order from your records:
completeThreeDSPayment({ ...body, orderId, amount, currency }). The bank resolves the
packets, and the resolved order must match yours before the card is charged.callbackUrl. The ResponseHash is verified, then the
result is confirmed with the bank: 3DModel charges with 3DModelPayment, is
checked with an order inquiry.Pre-authorization (block now, charge later) works on iyzico, Parampos, Sipay, Akbank and Albaraka Türk:
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-payment
Route (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. i18n puts
customer-facing error messages in errorMessage, in the customer's language. It works like
Better Auth's i18n plugin: English is built in, Turkish, German, Russian, and Arabic come in locales,
and the handler picks each response's language from Accept-Language, a cookie, or your own callback:
import { i18n, locales } from 'better-payment/plugins';
const payment = betterPayment({
providers: { ... },
plugins: [i18n({ translations: { tr: locales.tr }, defaultLocale: '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.
0.0.1 is the first release of a rebuilt library, not a patch on top of 3.x.
The 1.x–3.x releases are deprecated: a review found 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, so the version history was reset and 0.0.1 became the new baseline.
What changed:
TP_WMD_*, the Akbank Sanal POS
JSON API and iyzico payment statuses now match the official specifications.success, failure, pending and cancelled. A timeout is
pending with NETWORK_ERROR (the outcome is unknown), never a silent failure.
Payments are never retried automatically.authorize, and amounts can be set on the server.To upgrade, run npm install better-payment@latest (a ^3 range will never
resolve to 0.0.1), then follow the
migration guide.
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 sitepnpm install
pnpm test # package tests
pnpm lint
pnpm typecheck
pnpm build
| Albaraka ² | ✓ | ✓ 3D | ✓ | ✓ (same day) | ✓ ² | — | — |
| VakıfBank | ✓ | ✓ MPI + VPOS (3DS) | ✓ | ✓ (same day) | — | — | — |
| Yapı Kredi | ✓ | ✓ Posnet OOS | ✓ | ✓ (same day) | ✓ | — | — |
| QNB | ✓ | ✓ 3D Model / 3D Pay | ✓ | ✓ (next day) | ✓ | — | — |
3DPaycallbackUrl. The hash is rebuilt from the
fields in hashparams, then the result is confirmed with the bank: 3D provisions
with the XML API for the order from your records,
completeThreeDSPayment(callbackBody, { orderId, amount, currency }); 3D_PAY and
GarantiPay are checked with an order inquiry. An unsigned callback is only ever a
failure, without ids.callbackUrl is 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 replies
OK.