Skip to main content

Authentication

Every request is signed with HMAC-SHA256 using the secret issued with your API key. Nothing about your partner identity travels in a header — it comes from the key itself.

Headers

HeaderRequiredValue
X-API-Key-IDAlwaysYour API key ID
X-SignatureAlwaysHex HMAC-SHA256, built below
X-TimestampAlwaysRFC3339 UTC, within ±2 minutes of server time
X-NonceAlwaysUnique per request. A UUIDv4 is ideal
X-Idempotency-KeyWritesUnique per logical operation
X-Partner-IDOptionalAccepted, but must match the partner your key belongs to
Warning

X-Nonce must never repeat for the same key. A reused nonce is rejected with 409 REPLAYED_REQUEST, because a replayed request is indistinguishable from an attack. Generate a fresh UUID per attempt — including per retry.

Note

A retry uses a new nonce and a new timestamp, but the same reference. The nonce prevents replay; the reference prevents double crediting. They are different mechanisms and you need both.

The signature

Join six fields with \n, then HMAC-SHA256 with your secret and hex-encode:

METHOD \n PATH \n X-Timestamp \n X-Nonce \n X-Idempotency-Key \n SHA256_HEX(body)

The body is hashed, not inlined. Use the empty string for X-Idempotency-Key on read endpoints, and hash an empty body as SHA256_HEX("").

Go
package main

import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strings"
)

func Sign(secret, method, path, timestamp, nonce, idempotencyKey string, body []byte) string {
bodyHash := sha256.Sum256(body)

stringToSign := strings.Join([]string{
method,
path,
timestamp,
nonce,
idempotencyKey,
hex.EncodeToString(bodyHash[:]),
}, "\n")

mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(stringToSign))
return hex.EncodeToString(mac.Sum(nil))
}
Python
import hashlib
import hmac

def sign(secret, method, path, timestamp, nonce, idempotency_key, body: bytes) -> str:
body_hash = hashlib.sha256(body).hexdigest()

string_to_sign = "\n".join([
method,
path,
timestamp,
nonce,
idempotency_key,
body_hash,
])

return hmac.new(
secret.encode(),
string_to_sign.encode(),
hashlib.sha256,
).hexdigest()
Node.js
const crypto = require("crypto");

function sign(secret, method, path, timestamp, nonce, idempotencyKey, body) {
const bodyHash = crypto.createHash("sha256").update(body).digest("hex");

const stringToSign = [
method,
path,
timestamp,
nonce,
idempotencyKey,
bodyHash,
].join("\n");

return crypto.createHmac("sha256", secret).update(stringToSign).digest("hex");
}
Java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;

public final class SmartPaySigner {
public static String sign(String secret, String method, String path,
String timestamp, String nonce,
String idempotencyKey, byte[] body) throws Exception {
HexFormat hex = HexFormat.of();
String bodyHash = hex.formatHex(
MessageDigest.getInstance("SHA-256").digest(body));

String stringToSign = String.join("\n",
method, path, timestamp, nonce, idempotencyKey, bodyHash);

Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return hex.formatHex(mac.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8)));
}
}
Warning

Sign the exact bytes you put on the wire. Re-serialising the body after signing — a different key order, different whitespace — changes its hash and the signature will not match. Build the body once, sign those bytes, send those bytes.

A complete request

TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
NONCE=$(uuidgen)
IDEM=$(uuidgen)
BODY='{"reference":"UBA-20260819-0001","amount":50000,"currency":"SLE","msisdn":"+23276123456"}'
BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $2}')

SIG=$(printf 'POST\n/api/v1/inbound/credit\n%s\n%s\n%s\n%s' \
"$TS" "$NONCE" "$IDEM" "$BODY_HASH" \
| openssl dgst -sha256 -hmac "$SMARTPAY_HMAC_SECRET" -hex | awk '{print $2}')

curl -X POST https://api.smartpay.sl/api/v1/inbound/credit \
-H "Content-Type: application/json" \
-H "X-API-Key-ID: $SMARTPAY_API_KEY_ID" \
-H "X-Timestamp: $TS" \
-H "X-Nonce: $NONCE" \
-H "X-Idempotency-Key: $IDEM" \
-H "X-Signature: $SIG" \
-d "$BODY"

Scopes and origins

Your key carries inbound:read, inbound:write, or both — a read-only integration can hold a key that cannot move money.

If you give us an IP allowlist during onboarding, requests from anywhere else are refused with 403 IP_NOT_ALLOWED. Tell us before you change egress addresses.

Why a signature fails

SymptomUsual cause
INVALID_SIGNATUREBody re-serialised after signing, or the body was inlined instead of hashed
STALE_REQUESTServer clock drift. Run NTP; the window is ±2 minutes
REPLAYED_REQUESTThe nonce was reused. Generate a fresh one per attempt
PARTNER_MISMATCHX-Partner-ID disagrees with the key. Drop the header or correct it
SCOPE_DENIEDThe key lacks inbound:write