Authentication & Request Signing
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 API | Dashboard API | |
|---|---|---|
| Used for | /v1/payments, /v1/customers, /v1/installments, and similar merchant-server calls | Webhook endpoint configuration (/v1/stores/{storeId}/webhooks) and other dashboard-owned resources |
| Headers | X-Api-Key + X-Signature + X-Timestamp | Authorization: Bearer <jwt> |
| Credential | Your store's pk_/sk_ key pair | A session token from logging into the dashboard |
| Covered in | This guide | Webhooks |
This guide is about the signed public API β the one your backend uses to move money.
Required headers
| Header | Required | Example | Description |
|---|---|---|---|
X-Api-Key | Yes | pk_live_a1b2c3d4e5f6g7h8 | Your store's public key |
X-Signature | Yes | 9f8e7d6c5b4a⦠| Hex-encoded HMAC-SHA256 signature (see below) |
X-Timestamp | Yes | 2026-04-13T10:00:00Z | ISO 8601 UTC request time, validated within Β±5 minutes of the server clock |
X-Request-Id | Optional | 7b9e5c21-4a3f-4e8a-b9d2-1f4a8c9e5b10 | UUID v4. Kvell generates one if you omit it, and echoes it back β attach your own so it threads through your logs |
Idempotency-Key | Optional | d290f1ee-6c54-4b01-90e6-d701748f0851 | UUID 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:
{X-Timestamp}
{HTTP-METHOD}
{PATH}
{sha256-hex(request-body)}| Line | Value | Notes |
|---|---|---|
| 1 | The exact X-Timestamp value you're sending | Byte-for-byte the same string, not a re-formatted version |
| 2 | HTTP method | Uppercase β GET, POST, PUT, PATCH, DELETE |
| 3 | Request path | The route path only β no domain, no query string, and any {id} placeholder replaced with the real value |
| 4 | Body hash | Lowercase 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:
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:
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:
printf '' | shasum -a 256Step-by-step
- Generate
X-Timestampas the current UTC time in ISO 8601, with the trailingZ. - Uppercase the HTTP method.
- Take the route path exactly as requested β real resource ids substituted in, no query string, no trailing slash.
- 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).
- Join the four values with
\nto form the canonical string. - HMAC-SHA256 the canonical string with your secret key; hex-encode the result.
- Send
X-Api-Key,X-Timestamp, andX-Signatureon 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
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
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
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 withZ. - 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 β
409withKVELL-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
| Code | Meaning | Fix |
|---|---|---|
KVELL-GW-1001 | X-Timestamp missing, malformed, or outside the Β±5 minute window | Confirm your server clock is synced (NTP) and the timestamp is UTC ISO 8601 with a trailing Z |
KVELL-GW-1002 | Signature mismatch β the computed signature doesn't match what the server expects | Recompute the canonical string exactly: check method case, path (no query string, no domain), and that you hashed the exact bytes you sent |
KVELL-GW-1003 | X-Api-Key missing, unknown, or revoked | Confirm 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:
{
"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-Timestampin 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 onGETrequests with query parameters; no trailing slash; nohttps://or domain. - Method case β always uppercase (
POST, notpost) in the canonical string. - Empty body β hash the empty string (
""), not"{}", notnull, and not an omitted line.
Next steps
| Guide | What it covers |
|---|---|
| Accepting Card Payments | Put your signed requests to work: sessions, direct charges, 3DS |
| Webhooks | The other signature scheme β verifying what Kvell sends you |
| API Reference | Every endpoint's exact signature pattern, side by side with its request/response shape |