Accepting Card Payments
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 session | Direct API | |
|---|---|---|
| Card form | Kvell hosts it | You build it (or use an SDK's Elements-style component) |
| 3DS 2.0 challenge | Handled entirely by Kvell | You redirect the buyer and handle the callback |
| Installment picker | Rendered automatically when installmentEnabled: true | You call the installments API and render it yourself |
| PCI scope | Minimal β the PAN never reaches your frontend or backend | You must not let raw card data touch your server; tokenize in the browser |
| Best for | Fastest integration, standard checkout flows | Custom 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
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:
| Field | Description |
|---|---|
installmentEnabled | Show the installment picker on the hosted page β see Installment Payments |
customerId | Bind the session to a saved customer, which enables the save-card checkbox |
callbackUrl | Override 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:
{
"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:
curl https://api.pay.kvell.group/v1/payments/pay_1a2b3c4d5e6f/public-status{
"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:
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"
}'{
"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:
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:
| Field | Default | Notes |
|---|---|---|
installmentCount | 1 | 1β12, re-validated server-side against the eligible plans for the card's BIN and amount |
threeDSecure | true | Force the 3DS challenge; leave it on unless you have a specific reason not to |
capture | true | Set false to place a preauthorization instead of an immediate capture β see below |
saveCard | false | Attach 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:
{
"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:
{
"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:
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
statusfield in the charge response instead of assuming every charge triggers a redirect.
4. Read the payment status
| Status | Meaning |
|---|---|
new | Just created, no bank call made yet |
authorized | Bank approved a preauth (capture: false); no ledger entries posted, funds are held but not moved |
captured | Money moved β the terminal success state |
failed | Declined by the bank, or the 3DS challenge failed/expired |
refunded | Fully 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:
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:
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:
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.
{
"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:
{
"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
| Guide | What it covers |
|---|---|
| Installment Payments (Taksit) | BIN lookup, the picker, and commission math |
| Webhooks | Confirming payment.captured reliably instead of trusting redirects |
| Testing in the Sandbox | Force specific outcomes with X-Sim-Scenario β declines, 3DS challenges, timeouts |
| API Reference β Payments | Every field on every payments endpoint |