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
| Header | Required | Value |
|---|---|---|
X-API-Key-ID | Always | Your API key ID |
X-Signature | Always | Hex HMAC-SHA256, built below |
X-Timestamp | Always | RFC3339 UTC, within ±2 minutes of server time |
X-Nonce | Always | Unique per request. A UUIDv4 is ideal |
X-Idempotency-Key | Writes | Unique per logical operation |
X-Partner-ID | Optional | Accepted, but must match the partner your key belongs to |
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.
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("").
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))
}
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()
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");
}
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)));
}
}
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
| Symptom | Usual cause |
|---|---|
INVALID_SIGNATURE | Body re-serialised after signing, or the body was inlined instead of hashed |
STALE_REQUEST | Server clock drift. Run NTP; the window is ±2 minutes |
REPLAYED_REQUEST | The nonce was reused. Generate a fresh one per attempt |
PARTNER_MISMATCH | X-Partner-ID disagrees with the key. Drop the header or correct it |
SCOPE_DENIED | The key lacks inbound:write |