vellTRDocs
GuidesAccepting Card Payments

Accepting Card Payments

Intermediate25 min

Hosted checkout sessions, direct card charges, and the 3DS 2.0 challenge flow end to end.


There are two ways to accept a card payment with Kvell: hand the whole card form off to Kvell's hosted checkout, or collect the card yourself and charge a token from your server. Both end at the same place β€” a payment.captured webhook β€” so pick based on how much of the flow you want to own.

Choose an integration path

Hosted checkout sessionDirect API
Card formKvell hosts itYou build it (or use an SDK's Elements-style component)
3DS 2.0 challengeHandled entirely by KvellYou redirect the buyer and handle the callback
Installment pickerRendered automatically when installmentEnabled: trueYou call the installments API and render it yourself
PCI scopeMinimal β€” the PAN never reaches your frontend or backendYou must not let raw card data touch your server; tokenize in the browser
Best forFastest integration, standard checkout flowsCustom checkout UIs, native mobile, saved-card flows

Amounts are always integers in kuruş (1/100 TRY) across both paths β€” 15000 is 150.00 TRY. Currency is always "TRY".

Path A: Hosted checkout session

1. Create a session

Shell
curl -X POST https://api.pay.kvell.group/v1/payments/sessions \
  -H "X-Api-Key: pk_live_a1b2c3d4e5f6g7h8" \
  -H "X-Timestamp: 2026-04-13T10:00:00Z" \
  -H "X-Signature: <computed signature>" \
  -H "Content-Type: application/json" \
  -d '{
    "transactionId": "ORDER-2026-001",
    "amount": 15000,
    "currency": "TRY",
    "description": "Order #1234",
    "returnUrl": "https://merchant.example.com/return"
  }'

transactionId is your own order id and doubles as the idempotency fence β€” it's unique per merchant, so retrying the same order with the same id is always safe. A few other fields worth knowing about on this endpoint:

FieldDescription
installmentEnabledShow the installment picker on the hosted page β€” see Installment Payments
customerIdBind the session to a saved customer, which enables the save-card checkbox
callbackUrlOverride your store's default webhook URL for just this payment
mode"payment" (default), "card_update_only", or "qr" for a TR QR payment instead of a card form

The response is your hosted page:

JSON
{
  "sessionId": "3f1c9a2e-6b4d-4e8a-9c1f-2a7b8c9d0e1f",
  "checkoutUrl": "https://pay.kvell.group/s/3f1c9a2e-6b4d-4e8a-9c1f-2a7b8c9d0e1f",
  "expiresAt": "2026-04-13T10:30:00Z",
  "merchantId": "mrc_8a1b2c3d4e5f",
  "amount": 15000,
  "currency": "TRY",
  "status": "active"
}

2. Redirect the buyer

Send the buyer's browser to checkoutUrl. The session expires 30 minutes after creation (expiresAt) β€” create a fresh one if the buyer takes longer than that to check out.

3. Kvell handles the card form and 3DS

The hosted page collects the card, runs the 3DS 2.0 challenge when the issuer requires it, and shows the installment picker if you enabled it. You don't implement any of this β€” it's the entire point of this path.

4. The buyer returns to your returnUrl

Kvell redirects the buyer back once they're done, whether the payment succeeded or failed. Never mark an order paid based on this redirect alone β€” a buyer can close the tab before it fires, or navigate back manually without ever completing the charge. Treat the redirect as a hint to show a "processing" screen, and confirm the actual outcome from the payment.captured webhook event (see Webhooks).

5. Confirm the status

For a buyer-facing status page with no backend round-trip, poll the public status endpoint β€” it needs no signature or API key, since the payment id itself is the bearer:

Shell
curl https://api.pay.kvell.group/v1/payments/pay_1a2b3c4d5e6f/public-status
JSON
{
  "paymentId": "pay_1a2b3c4d5e6f",
  "status": "captured"
}

This is deliberately narrow β€” no amount, no card details, no merchant id β€” so it's safe to call directly from the buyer's browser. For the full record from your backend, use the signed GET /v1/payments/{id} instead.

Path B: Direct API integration

1. Tokenize the card in the browser

Collect the card fields in your own form, and send them straight from the browser to Kvell using only your public key β€” the raw PAN never touches your server:

Shell
curl -X POST https://api.pay.kvell.group/v1/cards/tokens \
  -H "X-Api-Key: pk_live_a1b2c3d4e5f6g7h8" \
  -H "Content-Type: application/json" \
  -d '{
    "pan": "4155650000000007",
    "expMonth": 12,
    "expYear": 2028,
    "cvv": "123"
  }'
JSON
{
  "token": "tok_9f8e7d6c5b4a",
  "last4": "0007",
  "bin": "415565",
  "brand": "visa"
}

The token is single-store-scoped and safe to hand to your own backend β€” it's useless outside your store's context.

2. Charge the token from your server

This call is signed with your secret key, since it's a server-to-server call that moves money:

Shell
curl -X POST https://api.pay.kvell.group/v1/payments/card \
  -H "X-Api-Key: pk_live_a1b2c3d4e5f6g7h8" \
  -H "X-Timestamp: 2026-04-13T10:00:00Z" \
  -H "X-Signature: <computed signature>" \
  -H "Content-Type: application/json" \
  -d '{
    "transactionId": "ORDER-2026-001",
    "amount": 15000,
    "currency": "TRY",
    "cardToken": "tok_9f8e7d6c5b4a",
    "installmentCount": 3
  }'

A few parameters worth knowing:

FieldDefaultNotes
installmentCount11–12, re-validated server-side against the eligible plans for the card's BIN and amount
threeDSecuretrueForce the 3DS challenge; leave it on unless you have a specific reason not to
capturetrueSet false to place a preauthorization instead of an immediate capture β€” see below
saveCardfalseAttach the card to customerId after a successful capture β€” only honored when the charge is tied to a session that already has a bound customerId

If the card doesn't need a 3DS challenge (see below), you get a terminal result immediately:

JSON
{
  "paymentId": "pay_1a2b3c4d5e6f",
  "status": "captured",
  "amount": 15000,
  "currency": "TRY",
  "commission": 449,
  "netAmount": 14551,
  "cardMask": "415565******4321",
  "cardBrand": "visa",
  "cardType": "credit",
  "installmentCount": 3,
  "threeDSecure": true,
  "authCode": "123456",
  "createdAt": "2026-04-13T10:00:00Z",
  "completedAt": "2026-04-13T10:00:02Z"
}

3. Handle the 3DS 2.0 challenge

If the issuer requires a challenge, the response looks different β€” branch on status:

JSON
{
  "status": "three_d_redirect",
  "threeDUrl": "https://acs.example.com/challenge/c0rr-3f1c-9a2e-6b4d",
  "correlationId": "c0rr-3f1c-9a2e-6b4d"
}

Redirect the buyer's browser to threeDUrl. After they complete the challenge, the ACS redirects them back with a pares assertion and the same correlationId in the query string β€” post both to finalize the charge:

Shell
curl -X POST https://api.pay.kvell.group/v1/payments/pay_1a2b3c4d5e6f/3ds-callback \
  -H "X-Api-Key: pk_live_a1b2c3d4e5f6g7h8" \
  -H "X-Timestamp: 2026-04-13T10:05:00Z" \
  -H "X-Signature: <computed signature>" \
  -H "Content-Type: application/json" \
  -d '{
    "pares": "eyJjaGFsbGVuZ2VJZCI6Ii4uLiJ9.9a8b7c6d",
    "correlationId": "c0rr-3f1c-9a2e-6b4d"
  }'

This call is idempotent β€” if the buyer's browser double-submits the callback, the second call returns the same terminal result rather than attempting a second capture.

Transactions under 150 TRY (15000 kuruş) may skip the 3DS challenge entirely under the Turkish regulatory baseline. Always branch on the status field in the charge response instead of assuming every charge triggers a redirect.

4. Read the payment status

StatusMeaning
newJust created, no bank call made yet
authorizedBank approved a preauth (capture: false); no ledger entries posted, funds are held but not moved
capturedMoney moved β€” the terminal success state
failedDeclined by the bank, or the 3DS challenge failed/expired
refundedFully refunded (see below)

Preauthorization and capture

Pass capture: false on the charge to place a hold without moving money β€” useful for flows like "authorize at order time, capture at ship time." The payment lands at status: "authorized".

When you're ready to take the funds:

Shell
curl -X POST https://api.pay.kvell.group/v1/payments/pay_1a2b3c4d5e6f/capture \
  -H "X-Api-Key: pk_live_a1b2c3d4e5f6g7h8" \
  -H "X-Timestamp: 2026-04-13T10:05:00Z" \
  -H "X-Signature: <computed signature>"

Capture is full-amount only (no partial capture yet) and idempotent against an already-captured row. If you're not going to take the funds at all, release the hold instead:

Shell
curl -X POST https://api.pay.kvell.group/v1/payments/pay_1a2b3c4d5e6f/void \
  -H "X-Api-Key: pk_live_a1b2c3d4e5f6g7h8" \
  -H "X-Timestamp: 2026-04-13T10:05:00Z" \
  -H "X-Signature: <computed signature>" \
  -H "Content-Type: application/json" \
  -d '{"reason": "Order abandoned"}'

A void moves no money β€” nothing is posted to your ledger, and the buyer's issuer releases the hold on their side.

Refunds

Refund a captured payment fully or partially:

Shell
curl -X POST https://api.pay.kvell.group/v1/payments/pay_1a2b3c4d5e6f/refund \
  -H "X-Api-Key: pk_live_a1b2c3d4e5f6g7h8" \
  -H "X-Timestamp: 2026-04-13T14:00:00Z" \
  -H "X-Signature: <computed signature>" \
  -H "Content-Type: application/json" \
  -d '{"amount": 5000, "reason": "Customer request"}'

Omit amount for a full refund; include it (capped at refundableAmount from GET /v1/payments/{id}) for a partial one.

JSON
{
  "paymentId": "pay_1a2b3c4d5e6f",
  "status": "captured",
  "refundAmount": 5000,
  "refundableAmount": 10000,
  "lastRefund": {
    "refundId": "rfn_7c8d9e0f1a2b",
    "status": "completed",
    "amount": 5000,
    "bankRefundId": "rfn_mock_44182",
    "createdAt": "2026-04-13T14:00:00Z",
    "completedAt": "2026-04-13T14:00:01Z"
  }
}

You'll get back 200 when the refund settles synchronously, or 202 when it's still in flight (the bank leg is finishing asynchronously). On a 202, don't mint a new Idempotency-Key and retry β€” poll GET /v1/payments/{id} until lastRefund.status flips to completed, or wait for the payment.refunded webhook.

Handling declines and errors

A bank decline or a business-rule rejection returns the standard error envelope:

JSON
{
  "code": "KVELL-PAY-4001",
  "message": "Payment declined by issuing bank.",
  "requestId": "7b9e5c21-4a3f-4e8a-b9d2-1f4a8c9e5b10"
}

Codes in the 4xxx range are business-rule outcomes, not bugs in your integration β€” a real decline, an ineligible installment count (see Installment Payments), or a stale 3DS session all land here. Log requestId alongside the order so you can correlate with support if a buyer disputes an outcome.

A declined or voided payment never posts anything to your ledger β€” no partial charge, no pending hold left dangling on your side.

Next steps

GuideWhat it covers
Installment Payments (Taksit)BIN lookup, the picker, and commission math
WebhooksConfirming payment.captured reliably instead of trusting redirects
Testing in the SandboxForce specific outcomes with X-Sim-Scenario β€” declines, 3DS challenges, timeouts
API Reference β€” PaymentsEvery field on every payments endpoint