Подпись запросов
Каждый запрос к API подписывается вашим ключом. Сессий и токенов нет: подпись проверяется в каждом запросе.
Пять заголовков
Заголовок раздела «Пять заголовков»| Заголовок | Значение |
|---|---|
X-Merchant-Key |
Ваш ключ, mka_test_… или mka_…. |
X-Timestamp |
Unix-время в секундах. Расходится с часами сервера не больше чем на ±5 минут. |
X-Nonce |
16–128 символов из A–Z a–z 0–9 _ -, новый для каждого запроса, включая повторы. |
X-Body-SHA256 |
SHA-256 точных байтов тела в hex нижнего регистра. Без тела — от пустой строки. |
X-Signature |
HMAC-SHA256 канонического запроса в hex нижнего регистра; ключ — ваш секрет. |
Канонический запрос
Заголовок раздела «Канонический запрос»Соедините пять строк через \n (без перевода строки в конце):
POST/v1/payments17926944000123456789abcdef0123456789abcdef1981bce37ff314cc0a7187703d73a6d469440bebcef6aa05bd4319e20e0521d6- Метод в верхнем регистре.
- Путь с query-строкой точно в том виде, в каком он отправлен, без хоста:
/v1/payouts?limit=10. - Время из
X-Timestamp. - Nonce из
X-Nonce. - Хеш тела из
X-Body-SHA256.
Ключ HMAC — это секрет, декодированный из base64url, а не текст секрета. Секрет — 43 символа base64url без выравнивания, после декодирования — 32 байта.
Модуль подписи
Заголовок раздела «Модуль подписи»Примеры каждого языка подписывают запросы через один небольшой модуль. Скопируйте его в свой проект как есть.
# Signs and sends one request to the Merchant Payments API with curl and openssl.# Source it from bash: . signed-request.sh# MKA_KEY, MKA_SECRET and MKA_BASE_URL come from the environment, never from code.
: "${MKA_BASE_URL:=https://sandbox.processing.yolotech.cc}"
# The HMAC key as hex: the secret is base64url text, the key the bytes it encodes.mka_hmac_key() { local secret padded secret=$(printf '%s' "$MKA_SECRET" | tr '_-' '/+') padded="$secret$(printf '%*s' $(( (4 - ${#secret} % 4) % 4 )) '' | tr ' ' '=')" printf '%s' "$padded" | openssl base64 -d -A | od -An -v -tx1 | tr -d ' \n'}
# mka_request METHOD TARGET [BODY] [IDEMPOTENCY_KEY]# TARGET is the path with its query. Prints the status line, then the body.mka_request() { local method=$1 target=$2 body=${3:-} key=${4:-} local timestamp nonce body_hash signature timestamp=$(date +%s) nonce=$(openssl rand -hex 16) # new on every request, retries included body_hash=$(printf '%s' "$body" | openssl dgst -sha256 -r | cut -d' ' -f1) signature=$(printf '%s\n%s\n%s\n%s\n%s' "$method" "$target" "$timestamp" "$nonce" "$body_hash" | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$(mka_hmac_key)" -r | cut -d' ' -f1)
local headers=(-H "X-Merchant-Key: $MKA_KEY" -H "X-Timestamp: $timestamp" -H "X-Nonce: $nonce" -H "X-Body-SHA256: $body_hash" -H "X-Signature: $signature") [ -n "$body" ] && headers+=(-H "Content-Type: application/json" --data-binary "$body") [ -n "$key" ] && headers+=(-H "Idempotency-Key: $key")
# MKA_DRY_RUN=1 prints the signed request instead of sending it. if [ -n "${MKA_DRY_RUN:-}" ]; then printf '{"method":"%s","target":"%s","body":%s,"headers":{"X-Merchant-Key":"%s","X-Timestamp":"%s","X-Nonce":"%s","X-Body-SHA256":"%s","X-Signature":"%s"}}\n' \ "$method" "$target" "$(printf '%s' "$body" | mka_json_string)" "$MKA_KEY" "$timestamp" "$nonce" "$body_hash" "$signature" return fi local head answer status replayed='' head=$(mktemp) answer=$(curl -sS -X "$method" "${headers[@]}" -D "$head" -w '\n%{http_code}' "$MKA_BASE_URL$target") || { rm -f "$head"; return 1; } grep -qi '^idempotent-replayed: *true' "$head" && replayed=' (replayed)' rm -f "$head" status=${answer##*$'\n'} printf 'HTTP %s%s\n' "$status" "$replayed" if [ -n "${answer%$'\n'*}" ]; then printf '%s\n' "${answer%$'\n'*}"; fi}
# A JSON string of stdin, for the dry run.mka_json_string() { sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' | awk 'BEGIN{ORS=""; printf "\""} NR>1{printf "\\n"} {print} END{printf "\""}'}
# A new Idempotency-Key. Keep it with the request you retry: a retry reuses it.mka_new_key() { openssl rand -hex 16; }
# An input the example needs, from the environment.mka_required() { if [ -z "${!1:-}" ]; then echo "set $1" >&2; exit 1; fi printf '%s' "${!1}"}// Signs and sends one request to the Merchant Payments API (Node 18 or later).// MKA_KEY, MKA_SECRET and MKA_BASE_URL come from the environment, never from code.import { createHash, createHmac, randomBytes, randomUUID } from "node:crypto";
const baseUrl = process.env.MKA_BASE_URL ?? "https://sandbox.processing.yolotech.cc";
/** * The five signature headers of one request. `target` is the path with its query; * the secret is base64url text, and the HMAC key is the bytes it encodes. */export function signatureHeaders({ key, secret, method, target, body, timestamp, nonce }) { const bodyHash = createHash("sha256").update(body).digest("hex"); const canonical = [method, target, String(timestamp), nonce, bodyHash].join("\n"); const hmacKey = Buffer.from(secret, "base64url"); return { "X-Merchant-Key": key, "X-Timestamp": String(timestamp), "X-Nonce": nonce, "X-Body-SHA256": bodyHash, "X-Signature": createHmac("sha256", hmacKey).update(canonical).digest("hex"), };}
/** Sends a signed request; answers its status, whether it repeats an earlier one, and its JSON. */export async function request(method, target, { body, idempotencyKey } = {}) { // Sign the exact bytes you send: serialise once, hash and send the same string. const raw = body === undefined ? "" : JSON.stringify(body); const headers = signatureHeaders({ key: process.env.MKA_KEY, secret: process.env.MKA_SECRET, method, target, body: raw, timestamp: Math.floor(Date.now() / 1000), nonce: randomBytes(16).toString("hex"), // new on every request, retries included }); if (raw) headers["Content-Type"] = "application/json"; if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
// MKA_DRY_RUN=1 prints the signed request instead of sending it. if (process.env.MKA_DRY_RUN) { console.log(JSON.stringify({ method, target, body: raw, headers })); return { status: 0, replayed: false, body: null }; } const response = await fetch(baseUrl + target, { method, headers, body: raw || undefined }); const text = await response.text(); return { status: response.status, replayed: response.headers.get("Idempotent-Replayed") === "true", body: text ? JSON.parse(text) : null, };}
/** A new Idempotency-Key. Keep it with the request you retry: a retry reuses it. */export const newKey = () => randomUUID();
/** An input the example needs, from the environment. */export function required(name) { const value = process.env[name]; if (!value) throw new Error(`set ${name}`); return value;}
/** Prints an answer: its status line, then its body. */export function print({ status, replayed, body }) { if (status === 0) return; console.log(`HTTP ${status}${replayed ? " (replayed)" : ""}`); if (body !== null) console.log(JSON.stringify(body, null, 2));}<?php// Signs and sends one request to the Merchant Payments API (PHP 8.1 or later, ext-curl).// MKA_KEY, MKA_SECRET and MKA_BASE_URL come from the environment, never from code.
declare(strict_types=1);
/** The five signature headers; the secret is base64url text, the HMAC key the bytes it encodes. */function mka_signature_headers(string $key, string $secret, string $method, string $target, string $body, int $timestamp, string $nonce): array{ $bodyHash = hash('sha256', $body); $canonical = implode("\n", [$method, $target, (string) $timestamp, $nonce, $bodyHash]); $hmacKey = base64_decode(strtr($secret, '-_', '+/'), true); return [ 'X-Merchant-Key' => $key, 'X-Timestamp' => (string) $timestamp, 'X-Nonce' => $nonce, 'X-Body-SHA256' => $bodyHash, 'X-Signature' => hash_hmac('sha256', $canonical, $hmacKey), ];}
/** Sends a signed request; answers [status, replayed, decoded JSON body]. */function mka_request(string $method, string $target, ?array $body = null, ?string $idempotencyKey = null): array{ // Sign the exact bytes you send: encode once, hash and send the same string. $raw = $body === null ? '' : json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); $headers = mka_signature_headers(getenv('MKA_KEY') ?: '', getenv('MKA_SECRET') ?: '', $method, $target, $raw, time(), bin2hex(random_bytes(16))); // a new nonce on every request, retries included if ($raw !== '') { $headers['Content-Type'] = 'application/json'; } if ($idempotencyKey !== null) { $headers['Idempotency-Key'] = $idempotencyKey; }
// MKA_DRY_RUN=1 prints the signed request instead of sending it. if (getenv('MKA_DRY_RUN')) { echo json_encode(['method' => $method, 'target' => $target, 'body' => $raw, 'headers' => $headers], JSON_UNESCAPED_SLASHES), "\n"; return [0, false, null]; } $lines = []; foreach ($headers as $name => $value) { $lines[] = "$name: $value"; } $replayed = false; $curl = curl_init((getenv('MKA_BASE_URL') ?: 'https://sandbox.processing.yolotech.cc') . $target); curl_setopt_array($curl, [ CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $lines, CURLOPT_POSTFIELDS => $raw === '' ? null : $raw, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, CURLOPT_HEADERFUNCTION => function ($curl, string $line) use (&$replayed): int { if (stripos($line, 'Idempotent-Replayed: true') === 0) { $replayed = true; } return strlen($line); }, ]); $text = curl_exec($curl); if ($text === false) { throw new RuntimeException(curl_error($curl)); } $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE); return [$status, $replayed, $text === '' ? null : json_decode($text, true, 512, JSON_THROW_ON_ERROR)];}
/** A new Idempotency-Key. Keep it with the request you retry: a retry reuses it. */function mka_new_key(): string{ return bin2hex(random_bytes(16));}
/** An input the example needs, from the environment. */function mka_required(string $name): string{ $value = getenv($name); if ($value === false || $value === '') { fwrite(STDERR, "set $name\n"); exit(1); } return $value;}
/** Prints an answer: its status line, then its body. */function mka_show(array $answer): void{ [$status, $replayed, $body] = $answer; if ($status === 0) { return; } echo "HTTP $status" . ($replayed ? ' (replayed)' : '') . "\n"; if ($body !== null) { echo json_encode($body, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), "\n"; }}"""Signs and sends one request to the Merchant Payments API (Python 3.9 or later, stdlib only).
MKA_KEY, MKA_SECRET and MKA_BASE_URL come from the environment, never from code."""import base64import hashlibimport hmacimport jsonimport osimport secretsimport timeimport urllib.errorimport urllib.requestimport uuid
BASE_URL = os.environ.get("MKA_BASE_URL", "https://sandbox.processing.yolotech.cc")
def signature_headers(key, secret, method, target, body, timestamp, nonce): """The five signature headers; the secret is base64url text, the HMAC key the bytes it encodes.""" body_hash = hashlib.sha256(body).hexdigest() canonical = "\n".join([method, target, str(timestamp), nonce, body_hash]) hmac_key = base64.urlsafe_b64decode(secret + "=" * (-len(secret) % 4)) return { "X-Merchant-Key": key, "X-Timestamp": str(timestamp), "X-Nonce": nonce, "X-Body-SHA256": body_hash, "X-Signature": hmac.new(hmac_key, canonical.encode(), hashlib.sha256).hexdigest(), }
def request(method, target, body=None, idempotency_key=None): """Sends a signed request; answers (status, replayed, JSON body).""" # Sign the exact bytes you send: serialise once, hash and send the same bytes. raw = b"" if body is None else json.dumps(body, separators=(",", ":")).encode() headers = signature_headers( os.environ["MKA_KEY"], os.environ["MKA_SECRET"], method, target, raw, int(time.time()), secrets.token_hex(16), # a new nonce on every request, retries included ) if raw: headers["Content-Type"] = "application/json" if idempotency_key: headers["Idempotency-Key"] = idempotency_key
# MKA_DRY_RUN=1 prints the signed request instead of sending it. if os.environ.get("MKA_DRY_RUN"): print(json.dumps({"method": method, "target": target, "body": raw.decode(), "headers": headers})) return 0, False, None http_request = urllib.request.Request(BASE_URL + target, data=raw or None, method=method, headers=headers) try: with urllib.request.urlopen(http_request, timeout=30) as response: status, text = response.status, response.read() replayed = response.headers.get("Idempotent-Replayed") == "true" except urllib.error.HTTPError as error: # 4xx and 5xx carry a body too status, text, replayed = error.code, error.read(), False return status, replayed, json.loads(text) if text else None
def new_key(): """A new Idempotency-Key. Keep it with the request you retry: a retry reuses it.""" return str(uuid.uuid4())
def required(name): """An input the example needs, from the environment.""" value = os.environ.get(name) if not value: raise SystemExit(f"set {name}") return value
def show(answer): """Prints an answer: its status line, then its body.""" status, replayed, body = answer if status == 0: return print(f"HTTP {status}" + (" (replayed)" if replayed else "")) if body is not None: print(json.dumps(body, indent=2, ensure_ascii=False))// Package signedrequest signs and sends one request to the Merchant Payments API.// MKA_KEY, MKA_SECRET and MKA_BASE_URL come from the environment, never from code.package signedrequest
import ( "bytes" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "os" "strconv" "strings" "time")
// Answer is a response: its status, whether it repeats an earlier create, and its JSON.type Answer struct { Status int Replayed bool Body map[string]any}
// SignatureHeaders are the five signature headers of one request. target is the// path with its query; the secret is base64url text, the HMAC key the bytes it encodes.func SignatureHeaders(key, secret, method, target string, body []byte, timestamp int64, nonce string) (map[string]string, error) { hmacKey, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(secret, "=")) if err != nil { return nil, fmt.Errorf("MKA_SECRET is not base64url: %w", err) } bodyHash := sha256.Sum256(body) canonical := strings.Join([]string{method, target, strconv.FormatInt(timestamp, 10), nonce, hex.EncodeToString(bodyHash[:])}, "\n") mac := hmac.New(sha256.New, hmacKey) mac.Write([]byte(canonical)) return map[string]string{ "X-Merchant-Key": key, "X-Timestamp": strconv.FormatInt(timestamp, 10), "X-Nonce": nonce, "X-Body-SHA256": hex.EncodeToString(bodyHash[:]), "X-Signature": hex.EncodeToString(mac.Sum(nil)), }, nil}
// Request sends a signed request. body is nil for a request without one.func Request(method, target string, body any, idempotencyKey string) (Answer, error) { // Sign the exact bytes you send: encode once, hash and send the same bytes. var raw []byte if body != nil { var err error if raw, err = json.Marshal(body); err != nil { return Answer{}, err } } headers, err := SignatureHeaders(os.Getenv("MKA_KEY"), os.Getenv("MKA_SECRET"), method, target, raw, time.Now().Unix(), NewKey()) // a new nonce on every request, retries included if err != nil { return Answer{}, err } if len(raw) > 0 { headers["Content-Type"] = "application/json" } if idempotencyKey != "" { headers["Idempotency-Key"] = idempotencyKey }
// MKA_DRY_RUN=1 prints the signed request instead of sending it. if os.Getenv("MKA_DRY_RUN") != "" { line, _ := json.Marshal(map[string]any{"method": method, "target": target, "body": string(raw), "headers": headers}) fmt.Println(string(line)) return Answer{}, nil } baseURL := os.Getenv("MKA_BASE_URL") if baseURL == "" { baseURL = "https://sandbox.processing.yolotech.cc" } request, err := http.NewRequest(method, baseURL+target, bytes.NewReader(raw)) if err != nil { return Answer{}, err } for name, value := range headers { request.Header.Set(name, value) } client := http.Client{Timeout: 30 * time.Second} response, err := client.Do(request) if err != nil { return Answer{}, err } defer response.Body.Close() text, err := io.ReadAll(response.Body) if err != nil { return Answer{}, err } answer := Answer{Status: response.StatusCode, Replayed: response.Header.Get("Idempotent-Replayed") == "true"} if len(text) > 0 { err = json.Unmarshal(text, &answer.Body) } return answer, err}
// NewKey is a new random Idempotency-Key or nonce. Keep a key with the request you// retry: a retry reuses it.func NewKey() string { b := make([]byte, 16) _, _ = rand.Read(b) return hex.EncodeToString(b)}
// Required is an input the example needs, from the environment.func Required(name string) string { value := os.Getenv(name) if value == "" { fmt.Fprintf(os.Stderr, "set %s\n", name) os.Exit(1) } return value}
// Env is an input with a default.func Env(name, fallback string) string { if value := os.Getenv(name); value != "" { return value } return fallback}
// Show prints an answer: its status line, then its body; it exits on an error.func Show(answer Answer, err error) { if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } if answer.Status == 0 { return } replayed := "" if answer.Replayed { replayed = " (replayed)" } fmt.Printf("HTTP %d%s\n", answer.Status, replayed) if answer.Body != nil { pretty, _ := json.MarshalIndent(answer.Body, "", " ") fmt.Println(string(pretty)) }}Проверьте свою подпись
Заголовок раздела «Проверьте свою подпись»Для этих входных данных ваш код должен давать эти подписи. Тесты самого сервера проверяют те же значения.
| Вход | Значение |
|---|---|
| Секрет | MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE |
| Время | 1792694400 |
| Nonce | 0123456789abcdef0123456789abcdef |
| Запрос | Тело | Подпись |
|---|---|---|
GET /v1/payments/019fccd2-3eef-4a33-80b8-cd750e2db99f |
пустое | 56d354416d979093c536ba60b1f267ccec4fd0db1ad0490b26b911d238be6be5 |
POST /v1/payments |
{"merchant_order_id":"order-1001","amount":"2500.00","currency":"LKR"} |
ccee5a223a9d2047bd93fc1ad9144cec5a27de6c1b76724c24a2ae068776cc8a |
У каждого примера есть пробный прогон: с MKA_DRY_RUN=1 он печатает подписанный запрос вместо отправки.
Если пришёл 401
Заголовок раздела «Если пришёл 401»Любая ошибка аутентификации — один и тот же 401 с {"title":"Unauthorized","status":401,"detail":"authentication failed"},
и это сделано намеренно. Проверьте по порядку:
- Ключ и установку. Ключи
mka_test_работают только в песочнице; отозванный ключ или ключ, заменённый больше 24 часов назад, перестаёт работать сразу. - Ключ HMAC. Декодируйте секрет из base64url; не используйте его текст.
- Тело. Хешируйте и отправляйте одни и те же байты. Если сериализовать JSON дважды — для хеша и для отправки, — в некоторых языках байты разойдутся.
- Путь. Подписывайте путь с query-строкой, экранированный так же, как при отправке, без хоста.
- Часы. Синхронизируйте сервер по NTP; запрос с расхождением больше 5 минут отклоняется.
- Nonce. Nonce используется один раз. Повтору нужны новые nonce и время — и тот же
Idempotency-Key.