Skip to content

Request Signing

FWallet supports HMAC-SHA256 request signing for server-to-server integrations that need stronger protection than a bearer API key. Signed keys prove that the caller has the signing secret, that the request body was not changed in transit, and that the request has not already been replayed.

Use request signing for high-risk flows such as transfers, payout initiation, webhook replay tooling, or any integration that operates on user assets.

If a key policy sets requireRequestSigning: true, the key must use authMode: "hmac". FWallet rejects that restriction on bearer secret keys because they cannot satisfy signed-request authentication.

Authentication Modes

FWallet API keys support two modes:

ModeHeadersUse case
secretX-API-KeyStandard bearer-key authentication.
hmacX-FWallet-Key-Id, timestamp, nonce, content hash, signatureServer-to-server calls that must be signed.

HMAC keys do not send the API key secret on every request. Instead, the client sends the public key id and signs a canonical request with the one-time signing secret returned when the key is created or rotated.

Create HMAC keys from the dashboard Developers page at /developers or with POST /v1/developer/api-keys using authMode: "hmac".

Required Headers

Every signed request must include:

HeaderDescription
X-FWallet-Key-IdThe API key id returned by FWallet.
X-FWallet-TimestampISO 8601 timestamp for the request, for example 2026-04-21T10:15:30Z.
X-FWallet-NonceUnique random value for this key inside the replay window. Use a UUID or at least 128 bits of randomness.
X-FWallet-Content-SHA256base64url(sha256(raw_request_body)). For an empty body, hash the empty byte string.
X-FWallet-Signaturev1=:base64url(hmac_sha256(signing_secret, canonical_request)):

For routes that create financial records, also send the normal Idempotency-Key header. If actor context is available, include:

X-FWallet-Actor-Type: tenant_user
X-FWallet-Actor-Id: user_123

Those optional values are part of the canonical request when present, so the signature binds the request to the actor context.

Signed cURL Shape

Most production callers should use an SDK or a small signing helper rather than hand-writing headers. A signed transfer request has this shape after the client computes the content hash and signature:

Terminal window
curl -X POST "https://api.fwallet.co.ug/v1/transfers?source=checkout" \
-H "Content-Type: application/json" \
-H "X-FWallet-Key-Id: ak_01JQHXYZ..." \
-H "X-FWallet-Timestamp: 2026-04-21T10:15:30Z" \
-H "X-FWallet-Nonce: 9d91a5ea-30f1-41a0-8b69-9f3d29125799" \
-H "X-FWallet-Content-SHA256: q8R8F4x4L6X5o9rALl8hN2mT7qQYd9w4Y9g7p3A2bL0" \
-H "X-FWallet-Signature: v1=:MEQCIB9...:" \
-H "Idempotency-Key: transfer_abc123" \
-H "X-FWallet-Actor-Type: tenant_user" \
-H "X-FWallet-Actor-Id: user_123" \
-d '{
"fromWalletId": "wl_sender",
"toWalletId": "wl_receiver",
"amount": 100000,
"currencyCode": "UGX"
}'

The body bytes sent with -d must be the exact bytes used to compute X-FWallet-Content-SHA256.

Canonical Request

Build the canonical request as newline-separated UTF-8 text:

v1
{timestamp}
{nonce}
{method}
{path_with_sorted_query}
{content_sha256}
{idempotency_key_or_empty}
{actor_type_or_empty}
{actor_id_or_empty}

Rules:

  • method is uppercase, for example POST.
  • path_with_sorted_query includes the path and query string, not the scheme or host.
  • Sort query parameters by key, then by value. Keep duplicate keys.
  • content_sha256 must be the exact value sent in X-FWallet-Content-SHA256.
  • Use empty strings for missing Idempotency-Key, actor type, or actor id.
  • Do not add an extra newline after the final line.

Example canonical request:

v1
2026-04-21T10:15:30Z
9d91a5ea-30f1-41a0-8b69-9f3d29125799
POST
/v1/transfers?dryRun=false&source=checkout
q8R8F4x4L6X5o9rALl8hN2mT7qQYd9w4Y9g7p3A2bL0
transfer_abc123
tenant_user
user_123

Verification Rules

FWallet rejects the request when any of these checks fail:

  • Timestamp is more than 5 minutes away from server time.
  • Nonce was already used for the same key inside the replay window.
  • Content hash does not match the raw request body.
  • Signature does not match the canonical request.
  • Key is revoked, expired, disabled, or not an HMAC key.
  • Route scope or key restrictions do not allow the operation.

TypeScript Signing Example

This example is intentionally close to SDK code. It uses Web Crypto, which is available in modern browsers, Bun, and recent Node.js versions.

const encoder = new TextEncoder();
async function signedFetch(input: {
url: string;
method: string;
body?: unknown;
keyId: string;
signingSecret: string;
idempotencyKey?: string;
actorType?: string;
actorId?: string;
}) {
const method = input.method.toUpperCase();
const body = input.body === undefined ? "" : JSON.stringify(input.body);
const timestamp = new Date().toISOString();
const nonce = crypto.randomUUID();
const contentSha256 = await sha256Base64Url(body);
const canonicalRequest = [
"v1",
timestamp,
nonce,
method,
pathWithSortedQuery(input.url),
contentSha256,
input.idempotencyKey ?? "",
input.actorType ?? "",
input.actorId ?? "",
].join("\n");
const signature = `v1=:${await hmacSha256Base64Url(
input.signingSecret,
canonicalRequest,
)}:`;
return fetch(input.url, {
method,
body: body || undefined,
headers: {
"Content-Type": "application/json",
"X-FWallet-Key-Id": input.keyId,
"X-FWallet-Timestamp": timestamp,
"X-FWallet-Nonce": nonce,
"X-FWallet-Content-SHA256": contentSha256,
"X-FWallet-Signature": signature,
...(input.idempotencyKey ? { "Idempotency-Key": input.idempotencyKey } : {}),
...(input.actorType ? { "X-FWallet-Actor-Type": input.actorType } : {}),
...(input.actorId ? { "X-FWallet-Actor-Id": input.actorId } : {}),
},
});
}
async function sha256Base64Url(value: string) {
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value));
return base64Url(new Uint8Array(digest));
}
async function hmacSha256Base64Url(secret: string, value: string) {
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(value));
return base64Url(new Uint8Array(signature));
}
function pathWithSortedQuery(url: string) {
const parsed = new URL(url);
const params = Array.from(parsed.searchParams.entries()).sort(([leftKey, leftValue], [rightKey, rightValue]) => {
if (leftKey === rightKey) return leftValue.localeCompare(rightValue);
return leftKey.localeCompare(rightKey);
});
const query = new URLSearchParams(params).toString();
return query ? `${parsed.pathname}?${query}` : parsed.pathname;
}
function base64Url(bytes: Uint8Array) {
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}

Python Signing Example

import base64
import hashlib
import hmac
import json
import uuid
from datetime import datetime, timezone
from urllib.parse import parse_qsl, urlencode, urlparse
def base64url(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("utf-8").rstrip("=")
def path_with_sorted_query(url: str) -> str:
parsed = urlparse(url)
params = sorted(parse_qsl(parsed.query, keep_blank_values=True))
query = urlencode(params)
return f"{parsed.path}?{query}" if query else parsed.path
def sign_request(url, method, body, key_id, signing_secret, idempotency_key=""):
raw_body = json.dumps(body, separators=(",", ":")).encode("utf-8") if body is not None else b""
timestamp = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
nonce = str(uuid.uuid4())
content_sha256 = base64url(hashlib.sha256(raw_body).digest())
canonical = "\n".join([
"v1",
timestamp,
nonce,
method.upper(),
path_with_sorted_query(url),
content_sha256,
idempotency_key,
"",
"",
])
digest = hmac.new(signing_secret.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256).digest()
return raw_body, {
"Content-Type": "application/json",
"X-FWallet-Key-Id": key_id,
"X-FWallet-Timestamp": timestamp,
"X-FWallet-Nonce": nonce,
"X-FWallet-Content-SHA256": content_sha256,
"X-FWallet-Signature": f"v1=:{base64url(digest)}:",
"Idempotency-Key": idempotency_key,
}

Common Failure Cases

ErrorLikely cause
MISSING_REQUEST_SIGNATURE_HEADEROne of the required X-FWallet-* signing headers is missing.
STALE_REQUEST_TIMESTAMPClient clock is outside the 5 minute skew window.
INVALID_REQUEST_CONTENT_HASHBody was serialized differently after hashing, or the wrong body was hashed.
INVALID_REQUEST_SIGNATURECanonical request differs from what FWallet computed, or the wrong signing secret was used.
REQUEST_NONCE_REPLAYEDThe same nonce was reused for the same key. Generate a fresh nonce per request.

SDK Notes

When implementing an SDK signer:

  • Serialize the body once, then hash and send exactly those bytes.
  • Generate timestamp, nonce, content hash, and signature as close to the request send as possible.
  • Keep the signer independent from transport code so it can support fetch, retries, and test fixtures.
  • Re-sign every retry with a new timestamp and nonce.
  • Never log the raw signing secret or canonical request when it may contain sensitive actor or idempotency data.

Observability

Signed and bearer-key requests appear in GET /v1/developer/request-logs and in the dashboard Developers area. Use correlationId, idempotencyKey, apiKeyId, and requestSignatureVerified filters when debugging SDK integrations.