Webhooks
Receive payment events, verify signatures, handle retries, and stay idempotent.
Webhooks are how you find out β asynchronously and reliably β that a payment captured, a refund completed, or a payout landed, without polling anything. This guide covers registering an endpoint, verifying what Kvell sends you, and processing deliveries safely.
How delivery works
Each event on your store triggers a signed HTTP POST to every endpoint subscribed to that event type. If your endpoint doesn't answer with a 2xx, Kvell retries on a fixed schedule; after the schedule is exhausted, the endpoint is marked degraded and the event moves to a dead-letter queue for investigation.
Webhook endpoint configuration goes through the dashboard API, not the signed public API β it's authenticated with your dashboard session (Authorization: Bearer <jwt>), not X-Api-Key/X-Signature. See Authentication & Request Signing for the distinction.
1. Register an endpoint
The fastest path is the dashboard: Integration β Webhooks β Add endpoint, paste an HTTPS URL, and pick the event types you want (or subscribe to everything).
To do it programmatically β useful if you're scripting environment setup β call the dashboard API with your session JWT:
curl -X POST https://api.pay.kvell.group/v1/stores/store_1a2b3c4d/webhooks \
-H "Authorization: Bearer <your-dashboard-jwt>" \
-H "Content-Type: application/json" \
-d '{
"url": "https://merchant.example.com/kvell/webhook",
"eventTypes": ["payment.captured", "payment.refunded", "payout.succeeded"]
}'{
"id": "whe_9a2e6b4d4e8a",
"merchantId": "mrc_8a1b2c3d4e5f",
"url": "https://merchant.example.com/kvell/webhook",
"secret": "whsec_2b3c4d5e6f708192a3b4c5d6",
"eventTypes": ["payment.captured", "payment.refunded", "payout.succeeded"],
"active": true,
"status": "active",
"createdAt": "2026-04-13T10:00:00Z"
}The
secretis returned exactly once. Store it in your secrets manager immediately β it's what you'll use to verify every delivery. If you lose it, delete the endpoint and create a new one.
You can also list your endpoints (GET /v1/stores/{storeId}/webhooks), disable one without deleting it (PATCH with {"active": false}), or remove one entirely (DELETE, returns 204).
2. The event catalog
Kvell only fans out events under four prefixes to merchant endpoints: payment.*, invoice.*, subscription.*, and payout.*. Subscribe to exactly the ones you act on:
| Event | Fires when |
|---|---|
payment.authorized | A preauth or a 3DS-pending charge is approved by the issuer |
payment.captured | Money has moved β your definitive "paid" signal |
payment.refunded | A refund (full or partial) has settled |
payment.failed | A charge was declined, or a 3DS challenge failed or expired |
payment.chargeback_received | The cardholder's bank has filed a chargeback against a captured payment |
invoice.paid | A hosted invoice was paid |
subscription.renewed | A recurring subscription cycle charged successfully |
payout.succeeded | A payout to your bank account or card landed |
3. What a delivery looks like
Every delivery is a POST with a JSON body and these headers:
| Header | Description |
|---|---|
X-Kvell-Event | The event type, e.g. payment.captured |
X-Kvell-Event-Id | Stable per logical event β use this as your dedup key |
X-Kvell-Delivery | Unique per delivery attempt β changes on every retry, don't dedup on this one |
X-Kvell-Signature | sha256=<hex HMAC-SHA256 of the raw body, keyed on your endpoint secret> |
X-Request-Id | Correlates this delivery with Kvell's internal logs |
{
"eventId": "evt_7c8d9e0f1a2b",
"eventType": "payment.captured",
"occurredAt": "2026-04-13T10:00:02Z",
"merchantId": "mrc_8a1b2c3d4e5f",
"requestId": "7b9e5c21-4a3f-4e8a-b9d2-1f4a8c9e5b10",
"payload": {
"paymentId": "pay_1a2b3c4d5e6f",
"amount": 15000,
"currency": "TRY"
}
}payload shape depends on eventType β a payment.captured payload mirrors the fields you'd get from GET /v1/payments/{id}.
4. Verify the signature
Always verify before trusting the body, and always hash the raw bytes β not a re-serialized version of the parsed JSON, which won't byte-match what Kvell signed.
TypeScript (Express)
import crypto from 'node:crypto';
import express from 'express';
const app = express();
// IMPORTANT: verify against the raw body β mount this BEFORE any JSON body
// parser runs on this route, or the bytes you hash won't match the wire body.
app.post('/kvell/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signatureHeader = req.header('X-Kvell-Signature') ?? '';
const expected =
'sha256=' +
crypto
.createHmac('sha256', process.env.KVELL_WEBHOOK_SECRET!)
.update(req.body) // Buffer, not parsed JSON
.digest('hex');
const isValid =
signatureHeader.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signatureHeader), Buffer.from(expected));
if (!isValid) {
return res.status(401).send('invalid signature');
}
const event = JSON.parse(req.body.toString('utf8'));
// ... process event (see the idempotency section below) ...
res.status(200).send('ok');
});Python (Flask)
import hmac
import hashlib
import os
from flask import Flask, request, abort
app = Flask(__name__)
@app.post('/kvell/webhook')
def kvell_webhook():
signature_header = request.headers.get('X-Kvell-Signature', '')
expected = 'sha256=' + hmac.new(
os.environ['KVELL_WEBHOOK_SECRET'].encode('utf-8'),
request.get_data(), # raw bytes, read before any JSON parsing
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(signature_header, expected):
abort(401, 'invalid signature')
event = request.get_json()
# ... process event (see the idempotency section below) ...
return 'ok', 200Both examples use a constant-time comparison (timingSafeEqual / compare_digest) rather than ===/== β a naive string comparison leaks timing information an attacker could use to guess the signature byte by byte.
5. Process idempotently
X-Kvell-Event-Id is stable for a given logical event even if Kvell has to retry the delivery β that's your dedup key, not X-Kvell-Delivery, which changes on every attempt.
async function handleWebhook(event: { eventId: string; eventType: string; payload: unknown }) {
if (await alreadyProcessed(event.eventId)) {
return; // duplicate delivery β already handled, ack and move on
}
await markProcessed(event.eventId);
await dispatch(event.eventType, event.payload);
}A unique constraint on event_id in your own datastore (or a Redis SETNX) is enough β you don't need anything fancier than "have I seen this id before."
6. Retry schedule
A non-2xx response β including a timeout β counts as a failed attempt. Kvell retries on this fixed schedule:
| Attempt | Delay after the previous failure |
|---|---|
| 1 | Immediate |
| 2 | 10 seconds |
| 3 | 1 minute |
| 4 | 10 minutes |
| 5 | 1 hour |
| 6 | 6 hours |
After attempt 6 fails, the endpoint is marked degraded and the event is parked for operator investigation. Respond within 10 seconds β if your handler does real work (charging a downstream system, sending an email), acknowledge with 200 immediately and do the work on a queue afterward. A slow synchronous handler burns a retry attempt on a timeout even if it would have eventually succeeded.
Once you've fixed a degraded endpoint, you can manually re-queue a specific delivery:
curl -X POST https://api.pay.kvell.group/v1/deliveries/whd_0f1a2b3c4d5e/resend \
-H "Authorization: Bearer <your-dashboard-jwt>"7. Inspect deliveries
List delivery attempts across your merchant β filterable by store and status β when you need to debug "did this webhook actually fire":
curl "https://api.pay.kvell.group/v1/merchants/mrc_8a1b2c3d4e5f/webhook-deliveries?status=failed&limit=20" \
-H "Authorization: Bearer <your-dashboard-jwt>"{
"items": [
{
"id": "whd_0f1a2b3c4d5e",
"endpointId": "whe_9a2e6b4d4e8a",
"eventType": "payment.captured",
"status": "delivered",
"attemptCount": 1,
"lastResponseCode": 200,
"createdAt": "2026-04-13T10:00:03Z"
}
]
}Local testing
During development, expose your local server with a tunnel (ngrok, Cloudflare Tunnel, or similar) and point a sandbox endpoint at the tunnel URL. Sandbox events are the same shape as production β see Testing in the Sandbox for triggering specific event types on demand. Swap in your real production URL before going live.
Best practices
- Respond fast, process async. Acknowledge with
200immediately, queue the actual work. - Verify before you trust. Never act on a webhook body you haven't signature-checked.
- Dedup on
X-Kvell-Event-Id, not on delivery id or arrival order. - HTTPS only. Kvell won't deliver to a plain-
http://URL. - Don't assume ordering. Two events for the same payment can arrive out of the order they occurred β treat the payment's current
status(viaGET /v1/payments/{id}) as truth, not the sequence webhooks arrived in. - Rotate a leaked secret immediately β delete the endpoint and recreate it; the old secret stops validating the moment the endpoint is gone.
- Log
X-Request-Idon every delivery you process β it's the fastest way to correlate a support ticket with Kvell's internal logs.
Next steps
| Guide | What it covers |
|---|---|
| Accepting Card Payments | Where payment.captured fits in the checkout flow |
| Authentication & Request Signing | The signed public API β a different scheme from webhook verification |
| API Reference β Webhooks | Every endpoint config and delivery-inspection route |