vellTRDocs
GuidesWebhooks

Webhooks

Intermediate15 min

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:

Shell
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"]
  }'
JSON
{
  "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 secret is 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:

EventFires when
payment.authorizedA preauth or a 3DS-pending charge is approved by the issuer
payment.capturedMoney has moved β€” your definitive "paid" signal
payment.refundedA refund (full or partial) has settled
payment.failedA charge was declined, or a 3DS challenge failed or expired
payment.chargeback_receivedThe cardholder's bank has filed a chargeback against a captured payment
invoice.paidA hosted invoice was paid
subscription.renewedA recurring subscription cycle charged successfully
payout.succeededA 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:

HeaderDescription
X-Kvell-EventThe event type, e.g. payment.captured
X-Kvell-Event-IdStable per logical event β€” use this as your dedup key
X-Kvell-DeliveryUnique per delivery attempt β€” changes on every retry, don't dedup on this one
X-Kvell-Signaturesha256=<hex HMAC-SHA256 of the raw body, keyed on your endpoint secret>
X-Request-IdCorrelates this delivery with Kvell's internal logs
JSON
{
  "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)

TypeScript
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)

Python
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', 200

Both 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.

TypeScript
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:

AttemptDelay after the previous failure
1Immediate
210 seconds
31 minute
410 minutes
51 hour
66 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:

Shell
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":

Shell
curl "https://api.pay.kvell.group/v1/merchants/mrc_8a1b2c3d4e5f/webhook-deliveries?status=failed&limit=20" \
  -H "Authorization: Bearer <your-dashboard-jwt>"
JSON
{
  "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 200 immediately, 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 (via GET /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-Id on every delivery you process β€” it's the fastest way to correlate a support ticket with Kvell's internal logs.

Next steps

GuideWhat it covers
Accepting Card PaymentsWhere payment.captured fits in the checkout flow
Authentication & Request SigningThe signed public API β€” a different scheme from webhook verification
API Reference β€” WebhooksEvery endpoint config and delivery-inspection route