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:
| Mode | Headers | Use case |
|---|---|---|
secret | X-API-Key | Standard bearer-key authentication. |
hmac | X-FWallet-Key-Id, timestamp, nonce, content hash, signature | Server-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:
| Header | Description |
|---|---|
X-FWallet-Key-Id | The API key id returned by FWallet. |
X-FWallet-Timestamp | ISO 8601 timestamp for the request, for example 2026-04-21T10:15:30Z. |
X-FWallet-Nonce | Unique random value for this key inside the replay window. Use a UUID or at least 128 bits of randomness. |
X-FWallet-Content-SHA256 | base64url(sha256(raw_request_body)). For an empty body, hash the empty byte string. |
X-FWallet-Signature | v1=: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_userX-FWallet-Actor-Id: user_123Those 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:
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:
methodis uppercase, for examplePOST.path_with_sorted_queryincludes the path and query string, not the scheme or host.- Sort query parameters by key, then by value. Keep duplicate keys.
content_sha256must be the exact value sent inX-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:
v12026-04-21T10:15:30Z9d91a5ea-30f1-41a0-8b69-9f3d29125799POST/v1/transfers?dryRun=false&source=checkoutq8R8F4x4L6X5o9rALl8hN2mT7qQYd9w4Y9g7p3A2bL0transfer_abc123tenant_useruser_123Verification 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 base64import hashlibimport hmacimport jsonimport uuidfrom datetime import datetime, timezonefrom 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
| Error | Likely cause |
|---|---|
MISSING_REQUEST_SIGNATURE_HEADER | One of the required X-FWallet-* signing headers is missing. |
STALE_REQUEST_TIMESTAMP | Client clock is outside the 5 minute skew window. |
INVALID_REQUEST_CONTENT_HASH | Body was serialized differently after hashing, or the wrong body was hashed. |
INVALID_REQUEST_SIGNATURE | Canonical request differs from what FWallet computed, or the wrong signing secret was used. |
REQUEST_NONCE_REPLAYED | The 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.