Signing requests
Every request to the API is signed with your key. There are no sessions and no tokens: the signature is checked on each request.
The five headers
Section titled “The five headers”| Header | Value |
|---|---|
X-Merchant-Key |
Your key, mka_test_… or mka_…. |
X-Timestamp |
Unix time in seconds. Within ±5 minutes of the server’s clock. |
X-Nonce |
16–128 characters of A–Z a–z 0–9 _ -, new for every request, retries included. |
X-Body-SHA256 |
Lowercase hex SHA-256 of the exact body bytes. Without a body, of the empty string. |
X-Signature |
Lowercase hex HMAC-SHA256 of the canonical request, keyed with your secret. |
The canonical request
Section titled “The canonical request”Join five lines with \n (no trailing newline):
POST/v1/payments17926944000123456789abcdef0123456789abcdef1981bce37ff314cc0a7187703d73a6d469440bebcef6aa05bd4319e20e0521d6- The method, upper case.
- The path with its query, exactly as sent, without the host:
/v1/payouts?limit=10. - The timestamp of
X-Timestamp. - The nonce of
X-Nonce. - The body hash of
X-Body-SHA256.
The HMAC key is the secret decoded from base64url, not the secret’s text. The secret is 43 characters of base64url without padding, 32 bytes once decoded.
The signing module
Section titled “The signing module”Each language’s examples sign through one small module. Copy it into your project as it is.
# 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)) }}Test your signing
Section titled “Test your signing”Your code should produce these signatures for these inputs. The server’s own tests check the same values.
| Input | Value |
|---|---|
| Secret | MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE |
| Timestamp | 1792694400 |
| Nonce | 0123456789abcdef0123456789abcdef |
| Request | Body | Signature |
|---|---|---|
GET /v1/payments/019fccd2-3eef-4a33-80b8-cd750e2db99f |
empty | 56d354416d979093c536ba60b1f267ccec4fd0db1ad0490b26b911d238be6be5 |
POST /v1/payments |
{"merchant_order_id":"order-1001","amount":"2500.00","currency":"LKR"} |
ccee5a223a9d2047bd93fc1ad9144cec5a27de6c1b76724c24a2ae068776cc8a |
Every example also has a dry run: MKA_DRY_RUN=1 prints the signed request instead of sending it.
When you get 401
Section titled “When you get 401”Every authentication failure is the same 401 with {"title":"Unauthorized","status":401,"detail":"authentication failed"},
on purpose. Check, in this order:
- The key and the installation.
mka_test_keys work only in the sandbox; a revoked key, or one replaced more than 24 hours ago, stops at once. - The HMAC key. Decode the secret from base64url; do not use its text.
- The body. Hash and send the same bytes. Serialising JSON twice — once to hash, once to send — gives different bytes in some languages.
- The path. Sign the path with its query, escaped as sent, without the host.
- The clock. Keep the server on NTP; more than 5 minutes off is refused.
- The nonce. A nonce is used once. A retry needs a new nonce and timestamp — and the same
Idempotency-Key.