Қысқаша
Webhook адресі интернетте ашық тұрады, сондықтан кез келген адам сізге «счёт төленді» деген сұрау жібере алады. Одан қорғайтын жалғыз нәрсе — қолтаңбаны тексеру.
Қолтаңба былай құралады:
signature = "sha256=" + hex( HMAC-SHA256( secret, timestamp + "." + rawBody ) )
Мұндағы timestamp — X-Webhook-Timestamp тақырыбындағы мән, rawBody — сұраудың денесі өзгертілмеген байт күйінде. Нәтижені X-Webhook-Signature тақырыбындағы мәнмен салыстырасыз.
Үш тексеру міндетті: қолтаңба сәйкес келсін, timestamp 5 минуттан ескі болмасын, өңдеу идемпотентті болсын.
Raw body неге міндетті
Ең жиі кездесетін қате осы. Фреймворктердің көбі денені автоматты түрде JSON-ға айналдырады, ал сіз оны қайтадан жолға айналдырғанда байттар өзгереді: бос орындар, өрістердің реті, Unicode таңбаларының жазылуы.
HMAC байт бойынша есептеледі. Бір байт өзгерсе, қолтаңба мүлдем басқа шығады.
| Қате тәсіл | Дұрыс тәсіл |
|---|---|
JSON.stringify(req.body) | req.body — Buffer күйінде |
json.dumps(request.json) | request.body немесе request.get_data() |
json_encode($data) | file_get_contents('php://input') |
Ереже: денені JSON-ға айналдырғанға дейін қолтаңбаны тексеріңіз, содан кейін ғана талдаңыз.
Timestamp тексеру
X-Webhook-Timestamp — жіберілген уақыт, Unix секунд. Ол 5 минуттан ескі болса, сұрауды қабылдамаңыз.
Бұл қайта жіберу шабуылынан қорғайды: біреу бұрын жарамды болған сұрауды ұстап алып, кейін қайта жіберсе, қолтаңбасы дұрыс болғанымен уақыты ескіріп қалады.
Серверіңіздің сағаты дұрыс жүріп тұрғанына көз жеткізіңіз — сағат бірнеше минутқа ауытқыса, жарамды сұраулар да қабылданбай қалады.
Express (Node.js)
Маңыздысы — express.json() осы маршрутқа дейін қолданылмауы керек.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
function verify(req) {
const ts = req.get('X-Webhook-Timestamp') || '';
const got = req.get('X-Webhook-Signature') || '';
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const mac = crypto
.createHmac('sha256', process.env.QUTPAY_WEBHOOK_SECRET)
.update(ts + '.')
.update(req.body) // Buffer, өзгертілмеген
.digest('hex');
const expected = 'sha256=' + mac;
const a = Buffer.from(expected);
const b = Buffer.from(got);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post('/qutpay-webhook', express.raw({ type: '*/*' }), (req, res) => {
if (!verify(req)) return res.sendStatus(401);
res.sendStatus(200);
const { event, invoice } = JSON.parse(req.body.toString('utf8'));
handle(event, invoice);
});
Салыстыруда қарапайым теңдіктің орнына timingSafeEqual қолданыңыз: әдеттегі салыстыру қолтаңбаны әріптеп табуға жол ашады.
Laravel (PHP)
Laravel денені де оқып қояды, бірақ $request->getContent() бастапқы байттарды береді. Бұл маршрутты CSRF тексеруінен шығарыңыз.
public function handle(Request $request)
{
$raw = $request->getContent();
$ts = $request->header('X-Webhook-Timestamp', '');
$got = $request->header('X-Webhook-Signature', '');
if (abs(time() - (int) $ts) > 300) {
return response('stale', 401);
}
$mac = hash_hmac('sha256', $ts . '.' . $raw, config('services.qutpay.webhook_secret'));
if (!hash_equals('sha256=' . $mac, $got)) {
return response('bad signature', 401);
}
$payload = json_decode($raw, true);
dispatch(new HandleQutPayEvent($payload)); // жұмысты кезекке
return response('', 200);
}
Таза PHP-де дене file_get_contents('php://input') арқылы алынады, тақырыптар — getallheaders().
Django (Python)
import hmac, hashlib, time, json
from django.conf import settings
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
@csrf_exempt
def qutpay_webhook(request):
raw = request.body # bytes, өзгертілмеген
ts = request.headers.get("X-Webhook-Timestamp", "")
got = request.headers.get("X-Webhook-Signature", "")
if abs(time.time() - int(ts or 0)) > 300:
return HttpResponse("stale", status=401)
mac = hmac.new(
settings.QUTPAY_WEBHOOK_SECRET.encode(),
ts.encode() + b"." + raw,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest("sha256=" + mac, got):
return HttpResponse("bad signature", status=401)
payload = json.loads(raw)
handle(payload)
return HttpResponse(status=200)
Flask-та дене request.get_data() арқылы алынады, қалғаны бірдей.
Фреймворксіз Node
import http from 'node:http';
http.createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const raw = Buffer.concat(chunks); // өзгертілмеген байттар
if (!verifyRaw(raw, req.headers)) {
res.writeHead(401).end();
return;
}
res.writeHead(200).end();
handle(JSON.parse(raw.toString('utf8')));
});
}).listen(3000);
Идемпотентті өңдеу
Қолтаңба дұрыс болса да, бір оқиға бірнеше рет келуі мүмкін: желі үзілді, сіз 200 қайтара алмадыңыз, біз қайталадық. Жалпы қайталау саны — 11 рет.
Сондықтан өңдеуді (invoice.id, status) жұбы бойынша бір рет орындаңыз. Ең қарапайым тәсіл — сол жұпты өз базаңызға бірегей кілтпен жазу:
CREATE TABLE qutpay_events (
invoice_id TEXT NOT NULL,
status TEXT NOT NULL,
handled_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (invoice_id, status)
);
const ins = await db.query(
'INSERT INTO qutpay_events (invoice_id, status) VALUES ($1, $2) ON CONFLICT DO NOTHING',
[invoice.id, invoice.status],
);
if (ins.rowCount === 0) return; // бұрын өңделген
await fulfil(invoice);
Толығырақ: Идемпоттылық.
Жиі кездесетін қателер
| Не істелді | Нәтижесі | Шешімі |
|---|---|---|
| Дене JSON-ға айналдырылған соң қайта жолға айналдырылды | Қолтаңба ешқашан сәйкес келмейді | Raw body қолданыңыз |
timestamp қолтаңбаға қосылмаған | Қолтаңба сәйкес келмейді | timestamp + "." + rawBody реті сақталсын |
sha256= префиксі салыстыруда ескерілмеген | Қолтаңба сәйкес келмейді | Префиксті қосып салыстырыңыз немесе екеуінен де алып тастаңыз |
| Құпия қате көшірілген, соңында бос орын бар | Қолтаңба сәйкес келмейді | Құпияны қайта көшіріңіз |
| Сервер сағаты ауытқыған | Жарамды сұраулар «ескі» болып қабылданбайды | Уақытты синхрондаңыз |
| Қолтаңба мүлдем тексерілмейді | Кез келген адам жалған «төленді» жібере алады | Тексеруді қосыңыз |
| Адрес авторизациямен жабылған | Webhook мүлдем келмейді | Адресті ашық қалдырып, қолтаңбамен қорғаңыз |
Қолтаңба сәйкес келмей жатса: Webhook қолтаңбасы сәйкес келмейді.
Қосымша шаралар
- Адрес табуға қиын болсын:
/qutpay-webhook-8f3a…сияқты. Бұл қолтаңбаны алмастырмайды, бірақ артық шу азаяды. - HTTPS міндетті, продакшенде басқасы қабылданбайды.
- Құпияны кодқа жазбаңыз, ортаның құпия айнымалысында сақтаңыз.
- Қолтаңбасы дұрыс емес сұрауларды журналға жазыңыз — шабуылды да, өз қатеңізді де осыдан көресіз.
- Толық тізім: Интеграция қауіпсіздігі: чек-парақ.
Жиі қойылатын сұрақтар
Қолтаңбаны тексермей-ақ қойсам бола ма? Болмайды. Ондай адреске кез келген адам «счёт төленді» деп жіберіп, тауарыңызды тегін алып кете алады.
IP бойынша сүзсем жеткілікті ме? Жоқ. IP адрестер өзгереді, ал қолтаңба деректің өзін дәлелдейді.
Құпия қашан ауысады? Сіз өзіңіз ауыстырғанда ғана. Жаңа құпия жасалған сәттен бастап қолданылады, серверіңізді бір мезетте жаңартыңыз.
Неге денеде төлемнің дәл уақыты жоқ? Kaspi төлем уақытын бермейді, сондықтан біз тек өзіміз білген сәтті бере аламыз.
Тексеруді қалай сынаймын? Кабинеттің Интеграциялар бөліміндегі сынау батырмасымен, сосын sandbox-та төлемді симуляциялап көріңіз.