vellTRDocs
GuidesAuthentication & Request Signing

Authentication & Request Signing

Beginner15 min

How Kvell's HMAC-SHA256 request signing works, with working signature examples in TypeScript, Python, and PHP.


Every merchant-server call to Kvell's public API is authenticated with an HMAC-SHA256 signature computed from your secret key β€” there's no bearer token to attach and forget. This guide covers exactly what bytes get signed, gives you working helpers in three languages, and lists the errors you'll see when something's off.

Two kinds of API access

Kvell's docs cover two distinct auth schemes β€” don't mix them up:

Signed public APIDashboard API
Used for/v1/payments, /v1/customers, /v1/installments, and similar merchant-server callsWebhook endpoint configuration (/v1/stores/{storeId}/webhooks) and other dashboard-owned resources
HeadersX-Api-Key + X-Signature + X-TimestampAuthorization: Bearer <jwt>
CredentialYour store's pk_/sk_ key pairA session token from logging into the dashboard
Covered inThis guideWebhooks

This guide is about the signed public API β€” the one your backend uses to move money.

Required headers

HeaderRequiredExampleDescription
X-Api-KeyYespk_live_a1b2c3d4e5f6g7h8Your store's public key
X-SignatureYes9f8e7d6c5b4a…Hex-encoded HMAC-SHA256 signature (see below)
X-TimestampYes2026-04-13T10:00:00ZISO 8601 UTC request time, validated within Β±5 minutes of the server clock
X-Request-IdOptional7b9e5c21-4a3f-4e8a-b9d2-1f4a8c9e5b10UUID v4. Kvell generates one if you omit it, and echoes it back β€” attach your own so it threads through your logs
Idempotency-KeyOptionald290f1ee-6c54-4b01-90e6-d701748f0851UUID v4. Replays of the same key return the original response, verbatim, for 24 hours

The canonical string

The signature is computed over this exact four-line string, joined with \n:

Shell
{X-Timestamp}
{HTTP-METHOD}
{PATH}
{sha256-hex(request-body)}
LineValueNotes
1The exact X-Timestamp value you're sendingByte-for-byte the same string, not a re-formatted version
2HTTP methodUppercase β€” GET, POST, PUT, PATCH, DELETE
3Request pathThe route path only β€” no domain, no query string, and any {id} placeholder replaced with the real value
4Body hashLowercase hex SHA-256 of the raw request body bytes. For a body-less request (GET, DELETE), this is the SHA-256 of the empty string

That whole string is then HMAC-SHA256'd using your secret key (sk_…), hex-encoded, and sent as X-Signature.

A worked shape for POST /v1/payments/card, with a real body:

Shell
2026-04-13T10:00:00Z
POST
/v1/payments/card
{sha256-hex of the exact JSON body bytes}

And for GET /v1/payments/pay_1a2b3c4d5e6f β€” note the path has the real id, not the literal string {id}, and carries no query string even when one is present in the actual request URL:

Shell
2026-04-13T10:00:00Z
GET
/v1/payments/pay_1a2b3c4d5e6f
{sha256-hex of the empty string}

That last line is the same fixed value on every body-less request (GET, DELETE), regardless of endpoint β€” compute it once in your language of choice and you'll recognize it forever:

Shell
printf '' | shasum -a 256

Step-by-step

  1. Generate X-Timestamp as the current UTC time in ISO 8601, with the trailing Z.
  2. Uppercase the HTTP method.
  3. Take the route path exactly as requested β€” real resource ids substituted in, no query string, no trailing slash.
  4. Hex-encode the SHA-256 hash of the raw body bytes you're about to send (or of an empty string, if there's no body).
  5. Join the four values with \n to form the canonical string.
  6. HMAC-SHA256 the canonical string with your secret key; hex-encode the result.
  7. Send X-Api-Key, X-Timestamp, and X-Signature on the request.

Signature helpers

Drop one of these into your backend β€” each takes the method, path, and raw body string, and returns the signature plus the timestamp it was computed against.

TypeScript / Node.js

TypeScript
import crypto from 'node:crypto';

export function signRequest(
  secretKey: string,
  method: string,
  path: string,
  body: string,
  timestamp: string = new Date().toISOString(),
): { signature: string; timestamp: string } {
  const bodyHash = crypto.createHash('sha256').update(body, 'utf8').digest('hex');
  const canonical = `${timestamp}\n${method.toUpperCase()}\n${path}\n${bodyHash}`;
  const signature = crypto.createHmac('sha256', secretKey).update(canonical, 'utf8').digest('hex');
  return { signature, timestamp };
}

// Usage: creating a payment session
const body = JSON.stringify({
  transactionId: 'ORDER-2026-001',
  amount: 15000,
  currency: 'TRY',
  description: 'Order #1234',
  returnUrl: 'https://merchant.example.com/return',
});

const { signature, timestamp } = signRequest(
  process.env.KVELL_SECRET_KEY!,
  'POST',
  '/v1/payments/sessions',
  body,
);

await fetch('https://api.pay.kvell.group/v1/payments/sessions', {
  method: 'POST',
  headers: {
    'X-Api-Key': process.env.KVELL_PUBLIC_KEY!,
    'X-Timestamp': timestamp,
    'X-Signature': signature,
    'Content-Type': 'application/json',
  },
  body, // send the exact same string you hashed
});

Python

Python
import hashlib
import hmac
from datetime import datetime, timezone

def sign_request(secret_key: str, method: str, path: str, body: str, timestamp: str | None = None) -> tuple[str, str]:
    timestamp = timestamp or datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
    body_hash = hashlib.sha256(body.encode('utf-8')).hexdigest()
    canonical = f"{timestamp}\n{method.upper()}\n{path}\n{body_hash}"
    signature = hmac.new(secret_key.encode('utf-8'), canonical.encode('utf-8'), hashlib.sha256).hexdigest()
    return signature, timestamp

# Usage: creating a payment session
import json
import os
import requests

body = json.dumps({
    "transactionId": "ORDER-2026-001",
    "amount": 15000,
    "currency": "TRY",
    "description": "Order #1234",
    "returnUrl": "https://merchant.example.com/return",
})

signature, timestamp = sign_request(os.environ["KVELL_SECRET_KEY"], "POST", "/v1/payments/sessions", body)

requests.post(
    "https://api.pay.kvell.group/v1/payments/sessions",
    data=body,  # send the exact same string you hashed
    headers={
        "X-Api-Key": os.environ["KVELL_PUBLIC_KEY"],
        "X-Timestamp": timestamp,
        "X-Signature": signature,
        "Content-Type": "application/json",
    },
)

PHP

PHP
<?php

function sign_request(string $secretKey, string $method, string $path, string $body, ?string $timestamp = null): array {
    $timestamp = $timestamp ?? gmdate('Y-m-d\TH:i:s\Z');
    $bodyHash = hash('sha256', $body);
    $canonical = "{$timestamp}\n" . strtoupper($method) . "\n{$path}\n{$bodyHash}";
    $signature = hash_hmac('sha256', $canonical, $secretKey);
    return [$signature, $timestamp];
}

// Usage: creating a payment session
$body = json_encode([
    'transactionId' => 'ORDER-2026-001',
    'amount' => 15000,
    'currency' => 'TRY',
    'description' => 'Order #1234',
    'returnUrl' => 'https://merchant.example.com/return',
]);

[$signature, $timestamp] = sign_request(getenv('KVELL_SECRET_KEY'), 'POST', '/v1/payments/sessions', $body);

$ch = curl_init('https://api.pay.kvell.group/v1/payments/sessions');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => $body, // send the exact same string you hashed
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'X-Api-Key: ' . getenv('KVELL_PUBLIC_KEY'),
        "X-Timestamp: {$timestamp}",
        "X-Signature: {$signature}",
        'Content-Type: application/json',
    ],
]);
$response = curl_exec($ch);

The official SDKs (Node.js, Python, PHP, Go, Java, .NET β€” see SDKs) compute all of this for you automatically; reach for a raw signing helper only when you're integrating directly against the HTTP API.

The timestamp window

X-Timestamp must be within Β±5 minutes of Kvell's server clock. This bounds the window an intercepted request could be replayed in. In practice:

  • Always generate the timestamp immediately before signing β€” don't precompute it and send the request later.
  • Use UTC. Never send a timezone offset (+03:00); always end with Z.
  • If your server's clock drifts, sync it with NTP β€” a few minutes of drift is enough to push every request outside the window.

Idempotency keys

Any state-changing call β€” creating a session, charging a card, issuing a refund β€” accepts an Idempotency-Key header (a UUID v4 you generate). Kvell caches the response for 24 hours, keyed on (your key, endpoint, idempotency key):

  • Same key, same payload β†’ you get back the original response, including the original HTTP status and X-Request-Id. Safe to retry after a timeout without double-charging anyone.
  • Same key, a different payload β†’ 409 with KVELL-GW-2001. This catches a bug where you reused a key for a different request by mistake.

POST /v1/payments/card has an additional wrinkle: its transactionId body field doubles as an idempotency fence even without an Idempotency-Key header, since a session already carries one. See Accepting Card Payments for the charge flow itself.

Common errors (1xxx) and how to fix them

CodeMeaningFix
KVELL-GW-1001X-Timestamp missing, malformed, or outside the Β±5 minute windowConfirm your server clock is synced (NTP) and the timestamp is UTC ISO 8601 with a trailing Z
KVELL-GW-1002Signature mismatch β€” the computed signature doesn't match what the server expectsRecompute the canonical string exactly: check method case, path (no query string, no domain), and that you hashed the exact bytes you sent
KVELL-GW-1003X-Api-Key missing, unknown, or revokedConfirm the header is present and that the key matches the environment you're calling (a pk_demo_… key against the production domain will fail)

Every error response follows the same envelope:

JSON
{
  "code": "KVELL-GW-1002",
  "message": "Signature verification failed.",
  "requestId": "7b9e5c21-4a3f-4e8a-b9d2-1f4a8c9e5b10"
}

Include requestId when you contact support β€” it's the fastest way to find your request in the logs.

Troubleshooting checklist

  • Clock skew β€” sync your server's clock with NTP; always generate X-Timestamp in UTC, right before signing.
  • Body-hash mismatch β€” hash the exact byte string you send on the wire. If you serialize the request body to compute the hash and then re-serialize it to send (different key ordering, different whitespace), the hashes won't match. Serialize once, hash that string, send that same string.
  • Path mismatch β€” sign the route's real path with real resource ids substituted in (/v1/payments/pay_1a2b3c4d5e6f, not /v1/payments/{id}); never include the query string, even on GET requests with query parameters; no trailing slash; no https:// or domain.
  • Method case β€” always uppercase (POST, not post) in the canonical string.
  • Empty body β€” hash the empty string (""), not "{}", not null, and not an omitted line.

Next steps

GuideWhat it covers
Accepting Card PaymentsPut your signed requests to work: sessions, direct charges, 3DS
WebhooksThe other signature scheme β€” verifying what Kvell sends you
API ReferenceEvery endpoint's exact signature pattern, side by side with its request/response shape