Подпись запросов
В интеграции два независимых механизма защиты, и нужны оба:
- исходящие запросы — ваш сервер доказывает платформе, что это вы, заголовком
X-Secure-Key; - входящие уведомления — платформа доказывает вашему серверу, что это она, заголовком
x-signature.
Оба используют один и тот же API-ключ из кабинета.
Авторизация запросов к API
Заголовок раздела «Авторизация запросов к API»Передавайте ключ в каждом запросе:
X-Secure-Key: <ваш API-ключ>POST /payment/pay-in HTTP/1.1Host: https://svc.cashwell.ioContent-Type: application/jsonX-Secure-Key: <ваш API-ключ>
{ "amount": 5000, "merchantTransactionId": "order-1001"}Если ключа нет или он не совпадает, вернётся 401 INVALID_API_KEY. Если ключ верный, но запрос пришёл с адреса вне списка разрешённых, — 403 IP_NOT_IN_WHITELIST.
Проверка подписи webhook
Заголовок раздела «Проверка подписи webhook»Каждое уведомление приходит с заголовком x-signature. Подпись считается так:
x-signature = HMAC_SHA256(тело_запроса, API-ключ) в hexЗдесь «тело запроса» — байты, которые пришли по сети, без изменений. Если ваш фреймворк распарсил JSON, а вы затем собрали его обратно в строку, порядок ключей и форматирование могут отличаться от исходных — подпись не сойдётся. Читайте raw body.
Порядок проверки
Заголовок раздела «Порядок проверки»- Получите тело запроса в исходном виде.
- Возьмите значение заголовка
x-signature. - Посчитайте HMAC-SHA256 от тела с API-ключом в роли секрета, результат — в hex.
- Сравните значения побайтово, в постоянном времени.
- Если не совпало — верните
401и не запускайте бизнес-логику.
Примеры реализации
Заголовок раздела «Примеры реализации»Node.js
Заголовок раздела «Node.js»const crypto = require('crypto');
function verifySignature(apiKey, rawBody, signature) { const expected = crypto.createHmac('sha256', apiKey).update(rawBody).digest('hex');
const received = Buffer.from(signature ?? '', 'utf8'); const computed = Buffer.from(expected, 'utf8');
// timingSafeEqual бросает исключение на буферах разной длины, // поэтому длину сверяем отдельно. return received.length === computed.length && crypto.timingSafeEqual(received, computed);}TypeScript (Express)
Заголовок раздела «TypeScript (Express)»import express from 'express';import crypto from 'crypto';
const app = express();
// raw body нужен именно до JSON-парсераapp.post('/callback', express.raw({ type: 'application/json' }), (req, res) => { const signature = String(req.header('x-signature') ?? ''); const expected = crypto.createHmac('sha256', process.env.API_KEY!).update(req.body).digest('hex');
const received = Buffer.from(signature, 'utf8'); const computed = Buffer.from(expected, 'utf8');
if (received.length !== computed.length || !crypto.timingSafeEqual(received, computed)) { return res.sendStatus(401); }
const trade = JSON.parse(req.body.toString('utf8')); // ... обработка return res.sendStatus(200);});import hmacimport hashlib
def verify_signature(api_key: str, raw_body: bytes, signature: str) -> bool: expected = hmac.new(api_key.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature or '')function verifySignature(string $apiKey, string $rawBody, string $signature): bool { $expected = hash_hmac('sha256', $rawBody, $apiKey); return hash_equals($expected, $signature);}import javax.crypto.Mac;import javax.crypto.spec.SecretKeySpec;import java.nio.charset.StandardCharsets;import java.security.MessageDigest;
public final class WebhookSignature {
public static boolean verify(String apiKey, byte[] rawBody, String signature) throws Exception { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(apiKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String expected = toHex(mac.doFinal(rawBody));
return MessageDigest.isEqual( expected.getBytes(StandardCharsets.UTF_8), signature == null ? new byte[0] : signature.getBytes(StandardCharsets.UTF_8) ); }
private static String toHex(byte[] data) { StringBuilder sb = new StringBuilder(data.length * 2); for (byte b : data) { sb.append(String.format("%02x", b)); } return sb.toString(); }}import ( "crypto/hmac" "crypto/sha256" "encoding/hex")
func verifySignature(apiKey string, rawBody []byte, signature string) bool { mac := hmac.New(sha256.New, []byte(apiKey)) mac.Write(rawBody) expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))}Если подпись не сходится
Заголовок раздела «Если подпись не сходится»| Симптом | Наиболее вероятная причина |
|---|---|
| Подпись не совпадает ни для одного уведомления | Тело читается после JSON-парсера и пересобирается заново |
| Совпадает не всегда | В теле есть символы вне ASCII, а хеш считается не от UTF-8 байтов |
| Совпадает на тесте, но не в проде | Используется ключ другого кабинета или ключ до ротации |
timingSafeEqual бросает исключение | Заголовок пустой или обрезан — длины буферов не совпали |
Что запомнить
Заголовок раздела «Что запомнить»- Подпись проверяется до обработки данных, а не после.
- Тело используется в неизменённом виде.
- Сравнение — в постоянном времени:
hash_equals,compare_digest,timingSafeEqual,MessageDigest.isEqual. - Запрос с неверной подписью отклоняется с
401и не повторяется вами вручную.