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 numbersprovisionUser,provisionPassword— the provisioning API user (PROVAUT)storeKey— key that signs the 3D request and verifies the 3D callbackrefundPassword(optional) — password of thePROVRFNuser; defaults toprovisionPasswordsecure3DStoreKey(optional) — only if the bank issued a separate 3D key; defaults tostoreKey
Request a test set for integration and a separate production set for go-live.
1. Install
npm install @fyio/payfyio2. 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 00Only 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.tsPoint 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 answersSource=GVPS/92. Verify reversals on your own test terminal.
Going live
- Swap in the production credential set — a live terminal, not the test one.
- Set
mode: 'production'. - Point
callbackUrlat your production HTTPS domain. - Run one real low-value 3D payment and refund it.
Troubleshooting
| Symptom | Cause |
|---|---|
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 page | mode doesn't match the host, or callbackUrl differs between the form and what you registered |
Source=HOST, code 05 | The request authenticated fine; the bank's host declined the transaction |
Callback arrives but status: 'failure' with a hash message | The 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 page | callbackUrl isn't publicly reachable (localhost) |