payfyio
Banks

Garanti BBVA

Garanti BBVA Sanal POS (GVP) — install, wire up, take a 3D Secure payment.

Garanti BBVA Sanal POS (GVP), API version 512. XML over HTTPS to /VPServlet for direct operations, and an auto-submitting form to the /servlet/gt3dengine 3D engine for 3D Secure. Every request is signed SHA512(… + SHA1(provisionPassword + terminalId)), and every 3D callback is verified against the same store key before it is trusted.

Getting your credentials

GVP credentials come from your Garanti BBVA member-merchant (üye işyeri) agreement — they are not self-service. Obtain them from the bank's VPOS/merchant team (or your integrator):

  • merchantId, terminalId — your merchant and terminal numbers
  • provisionUser, provisionPassword — the provisioning API user (PROVAUT)
  • storeKey — key that signs the 3D request and verifies the 3D callback
  • refundPassword (optional) — password of the PROVRFN user; defaults to provisionPassword
  • secure3DStoreKey (optional) — only if the bank issued a separate 3D key; defaults to storeKey

Request a test set for integration and a separate production set for go-live.

1. Install

npm install @fyio/payfyio

2. Put the credentials in your environment

# .env
GARANTI_MERCHANT_ID=7000679
GARANTI_TERMINAL_ID=30691297
GARANTI_PROVISION_USER=PROVAUT
GARANTI_PROVISION_PASSWORD=123qweASD/
GARANTI_STORE_KEY=12345678
# optional
GARANTI_REFUND_PASSWORD=
GARANTI_3D_STORE_KEY=

Setting env vars is not enough on its own. payfyio never reads process.env — it has no knowledge of your variable names. The .env file only feeds the config object you build in the next step. If you skip step 3, nothing is configured.

3. Create the Payfyio instance

One module, exported once and reused — not per request.

// lib/payment.ts
import { Payfyio, ProviderType } from '@fyio/payfyio';

export const payment = new Payfyio({
  // 'sandbox' → sanalposprovtest.garantibbva.com.tr + Mode=TEST
  // 'production' → sanalposprov.garantibbva.com.tr + Mode=PROD
  mode: process.env.NODE_ENV === 'production' ? 'production' : 'sandbox',
  defaultProvider: ProviderType.GARANTI,
  providers: {
    garanti: {
      enabled: true,
      config: {
        merchantId: process.env.GARANTI_MERCHANT_ID!,
        terminalId: process.env.GARANTI_TERMINAL_ID!,
        provisionUser: process.env.GARANTI_PROVISION_USER!,
        provisionPassword: process.env.GARANTI_PROVISION_PASSWORD!,
        storeKey: process.env.GARANTI_STORE_KEY!,
        refundPassword: process.env.GARANTI_REFUND_PASSWORD,
        secure3DStoreKey: process.env.GARANTI_3D_STORE_KEY,
      },
    },
  },
});

Do not set baseUrl by hand — mode selects the right host and tags the request Mode=TEST / Mode=PROD. A PROD-mode request on the test host is rejected by the bank, and the reverse is how sandbox credentials leak into live traffic.

Missing fields throw at construction, so a bad deploy fails on boot rather than on the first customer's card.

4. Take a 3D Secure payment

Two routes: one starts the payment, one receives the bank's answer.

Route A — start

// POST /checkout
import { payment } from '@/lib/payment';

const orderId = 'ORDER-' + Date.now(); // your own id — this is the paymentId later

const init = await payment.garanti.initThreeDSPayment({
  conversationId: orderId,      // becomes GVP OrderID; omit and one is generated
  price: '100.00',
  paidPrice: '100.00',
  currency: 'TRY',
  installment: 1,               // >1 for taksit
  basketId: orderId,
  callbackUrl: 'https://yoursite.com/garanti/callback',
  paymentCard: {
    cardHolderName: 'John Doe',
    cardNumber: '4282209004348015',
    expireMonth: '08',
    expireYear: '2027',
    cvc: '123',
  },
  buyer: {
    id: 'BY-1',
    name: 'John',
    surname: 'Doe',
    email: 'john@example.com',
    identityNumber: '74300864791',
    registrationAddress: 'Merdivenköy Mah.',
    city: 'Istanbul',
    country: 'Turkey',
    ip: '85.34.78.112',
    gsmNumber: '+905350000000',
  },
  // Required by the shared request type; GVP itself only reads the fields above.
  shippingAddress: { contactName: 'John Doe', city: 'Istanbul', country: 'Turkey', address: 'Merdivenköy Mah.' },
  billingAddress: { contactName: 'John Doe', city: 'Istanbul', country: 'Turkey', address: 'Merdivenköy Mah.' },
  basketItems: [{ id: 'BI-1', name: 'Product', category1: 'General', itemType: 'VIRTUAL', price: '100.00' }],
});

// init.status === 'pending', init.paymentId === orderId
// Send this HTML to the browser as-is — it auto-POSTs to GVP's 3D engine.
res.setHeader('content-type', 'text/html; charset=utf-8');
res.send(init.threeDSHtmlContent);

Persist init.paymentId with the order before you respond. That id is what comes back in the callback, and it is how you match the bank's answer to a cart.

Route B — callback

The bank posts back form-encoded, so make sure your framework parses that body (express.urlencoded({ extended: false }), or await req.formData() in a Next.js route handler). Then hand the whole body over:

// POST /garanti/callback   (must be the exact callbackUrl above)
import { payment } from '@/lib/payment';

const result = await payment.garanti.completeThreeDSPayment(req.body);

if (result.status === 'success') {
  await markOrderPaid(result.paymentId!);   // your orderId
} else {
  await markOrderFailed(result.paymentId!, result.errorCode, result.errorMessage);
}

callbackUrl must be a publicly reachable HTTPS URL — the browser is redirected there from the bank's page, and modern browsers block a public page from posting to localhost. For local development, tunnel it (ngrok http 3000) and use the tunnel URL.

3DS runs at GVP's 3D_PAY security level: the bank authenticates the cardholder and provisions in one leg, then posts the final result to your callbackUrl. There is no second call to make.

Why you can trust the callback

completeThreeDSPayment verifies the response hash before it reads mdstatus or procreturncode, requires the bank's signed field list to actually cover those two fields, and compares in constant time. A forged POST to your callback route cannot mark an unpaid order as paid.

The charged amount is bound at init inside secure3dhash — the bank provisions the amount it signed — which is why the callback's unsigned txnamount echo is never trusted. Compare against your own stored order total, not the callback.

If you need the check outside the provider (a webhook router, an edge function):

import { verifyGaranti3DHash } from '@fyio/payfyio';

if (!verifyGaranti3DHash(req.body, process.env.GARANTI_STORE_KEY!)) {
  return res.status(401).end();
}

Payment without 3D Secure

Same request shape, one call, no callback route:

const result = await payment.garanti.createPayment({ /* same fields, no callbackUrl */ });
// result.status === 'success' when the bank answers ReasonCode 00

Only for terminals allowed to run non-3D traffic. 3D Secure shifts chargeback liability to the issuer; non-3D leaves it with you.

Refund / Cancel / Get

await payment.garanti.refund({ paymentId: orderId, price: '100.00', currency: 'TRY', ip: '85.34.78.112' });
await payment.garanti.cancel({ paymentId: orderId, ip: '85.34.78.112' });
await payment.garanti.getPayment(orderId);

paymentId is the orderId you sent at init. cancel voids the whole authorisation (GVP void, same day); refund takes an amount and supports partial refunds. Both run as the PROVRFN provision user — if that user has its own password on your terminal, set refundPassword.

Testing

Set mode: 'sandbox'; baseUrl switches to https://sanalposprovtest.garantibbva.com.tr and requests are tagged Mode=TEST automatically. The public test terminal from Garanti's docs is the one in the .env sample above.

Test card 4282209004348015 (08/27, CVV 123), 3D OTP 147852.

The library ships a sandbox suite that runs a non-3D sale, an order inquiry and a full 3D_PAY round trip (form → 3D engine → callback → completeThreeDSPayment) against the bank:

GARANTI_E2E=1 npx vitest run tests/e2e/garanti.e2e.test.ts

Point it at your own terminal with GARANTI_MERCHANT_ID / GARANTI_TERMINAL_ID / GARANTI_PROVISION_USER / GARANTI_PROVISION_PASSWORD / GARANTI_STORE_KEY.

The shared public test terminal declines every void/refund at the host (Source=HOST, ReasonCode 05, RPC-05 condition was raised) while an unauthenticated request answers Source=GVPS / 92. Verify reversals on your own test terminal.

Going live

  1. Swap in the production credential set — a live terminal, not the test one.
  2. Set mode: 'production'.
  3. Point callbackUrl at your production HTTPS domain.
  4. Run one real low-value 3D payment and refund it.

Troubleshooting

SymptomCause
Kullanıcı şifresi hatalı (Source=GVPS, code 92)Wrong provisionPassword / terminalId / storeKey, or a refund signed with the wrong refundPassword
Hash error on the 3D pagemode doesn't match the host, or callbackUrl differs between the form and what you registered
Source=HOST, code 05The request authenticated fine; the bank's host declined the transaction
Callback arrives but status: 'failure' with a hash messageThe body wasn't parsed as form-encoded, or you passed a subset of the fields instead of the whole body
Nothing happens after the 3D pagecallbackUrl isn't publicly reachable (localhost)

On this page