Перейти к содержимому

Подпись запросов

В интеграции два независимых механизма защиты, и нужны оба:

  • исходящие запросы — ваш сервер доказывает платформе, что это вы, заголовком X-Secure-Key;
  • входящие уведомления — платформа доказывает вашему серверу, что это она, заголовком x-signature.

Оба используют один и тот же API-ключ из кабинета.

Передавайте ключ в каждом запросе:

X-Secure-Key: <ваш API-ключ>
POST /payment/pay-in HTTP/1.1
Host: https://svc.cashwell.io
Content-Type: application/json
X-Secure-Key: <ваш API-ключ>
{
"amount": 5000,
"merchantTransactionId": "order-1001"
}

Если ключа нет или он не совпадает, вернётся 401 INVALID_API_KEY. Если ключ верный, но запрос пришёл с адреса вне списка разрешённых, — 403 IP_NOT_IN_WHITELIST.

Каждое уведомление приходит с заголовком x-signature. Подпись считается так:

x-signature = HMAC_SHA256(тело_запроса, API-ключ) в hex

Здесь «тело запроса» — байты, которые пришли по сети, без изменений. Если ваш фреймворк распарсил JSON, а вы затем собрали его обратно в строку, порядок ключей и форматирование могут отличаться от исходных — подпись не сойдётся. Читайте raw body.

  1. Получите тело запроса в исходном виде.
  2. Возьмите значение заголовка x-signature.
  3. Посчитайте HMAC-SHA256 от тела с API-ключом в роли секрета, результат — в hex.
  4. Сравните значения побайтово, в постоянном времени.
  5. Если не совпало — верните 401 и не запускайте бизнес-логику.
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);
}
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 hmac
import 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 и не повторяется вами вручную.