Skip to main content

HMAC Authentication

This page walks through the complete HMAC-SHA256 signature computation process for authenticating API requests to Lampay. Every request to /api/** endpoints must be signed using this procedure.

Signature Computation

The signature is computed in three steps: hash the request body, build a canonical message string, and compute the HMAC.

Step 1: Compute the Body Hash

Compute the SHA-256 hash of the raw request body and encode it as a lowercase hexadecimal string.

bodyHash = lowercase_hex(SHA-256(requestBody))

For requests with a body (POST, PUT, PATCH), hash the exact JSON string you will send:

echo -n '{"sourceWalletId":"w_123","amount":"100.00"}' | openssl dgst -sha256 -hex
# Output: a1fce4363854ff888cff4b8e7875d600c...

For requests without a body (GET, DELETE), use the SHA-256 hash of an empty string:

e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
Body encoding matters

The body hash is computed over the exact byte sequence sent in the HTTP body. If your JSON serializer produces {"a": 1} (with a space after the colon) but you hash {"a":1} (without the space), the signature will not match. Always hash the same string you pass to your HTTP client.

Step 2: Build the Canonical Message

Concatenate five components separated by newline characters (\n):

canonicalMessage = method + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + bodyHash
ComponentDescriptionExample
methodHTTP method, uppercasePOST
pathRequest path only -- no scheme, host, or query string/api/v1/transfers
timestampUnix epoch seconds (UTC), same value as the X-Timestamp header1709337600
nonceUnique identifier, same value as the X-Nonce header (UUID v4 recommended)550e8400-e29b-41d4-a716-446655440000
bodyHashSHA-256 hex digest from Step 1a1fce436...

For a POST /api/v1/transfers request, the canonical message would look like:

POST
/api/v1/transfers
1709337600
550e8400-e29b-41d4-a716-446655440000
a1fce4363854ff888cff4b8e7875d600c...
Path must not include query string

For GET requests with query parameters (e.g., /api/v1/wallets?page=0&size=20), only include the path portion in the canonical message: /api/v1/wallets. Query parameters are excluded from signature computation.

Step 3: Compute the HMAC Signature

Sign the canonical message using HMAC-SHA256 with your API secret, then Base64-encode the result:

signature = Base64(HMAC-SHA256(canonicalMessage, apiSecret))

The resulting Base64 string goes into the X-Signature header.


Complete Examples

Bash + curl

The following script demonstrates a complete signed API request:

#!/bin/bash
# Lampay API Integration -- HMAC-SHA256 Authentication Example

# ─── Configuration ────────────────────────────────────────────
API_KEY="sk_live_abc123def456"
API_SECRET="your-api-secret-here"
BASE_URL="https://api.lampay.app"

# ─── Request parameters ──────────────────────────────────────
METHOD="POST"
PATH_URI="/api/v1/transfer/command/create"
TIMESTAMP=$(date +%s)
NONCE=$(uuidgen | tr '[:upper:]' '[:lower:]')

# Request body (must be the exact string sent over the wire)
BODY='{"sourceWalletId":"w_123","targetWalletId":"w_456","amount":"100.00","currency":"USD"}'

# ─── Step 1: Body hash ───────────────────────────────────────
BODY_HASH=$(echo -n "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')

# ─── Step 2: Canonical message ────────────────────────────────
CANONICAL="${METHOD}\n${PATH_URI}\n${TIMESTAMP}\n${NONCE}\n${BODY_HASH}"

# ─── Step 3: HMAC-SHA256 signature ────────────────────────────
SIGNATURE=$(echo -ne "$CANONICAL" | openssl dgst -sha256 -hmac "$API_SECRET" -binary | openssl base64 -A)

# ─── Send the request ─────────────────────────────────────────
curl -X "$METHOD" "${BASE_URL}${PATH_URI}" \
-H "Content-Type: application/json" \
-H "X-Api-Key: $API_KEY" \
-H "X-Signature: $SIGNATURE" \
-H "X-Timestamp: $TIMESTAMP" \
-H "X-Nonce: $NONCE" \
-d "$BODY"

For GET requests, omit the -d flag and use the empty-string body hash:

METHOD="GET"
PATH_URI="/api/v1/wallets"
BODY=""
BODY_HASH="e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"

CANONICAL="${METHOD}\n${PATH_URI}\n${TIMESTAMP}\n${NONCE}\n${BODY_HASH}"
SIGNATURE=$(echo -ne "$CANONICAL" | openssl dgst -sha256 -hmac "$API_SECRET" -binary | openssl base64 -A)

curl -X "$METHOD" "${BASE_URL}${PATH_URI}" \
-H "X-Api-Key: $API_KEY" \
-H "X-Signature: $SIGNATURE" \
-H "X-Timestamp: $TIMESTAMP" \
-H "X-Nonce: $NONCE"

Node.js

A reusable function for signing and sending API requests:

import crypto from 'crypto';

/**
* Send an authenticated request to the Lampay API.
*
* @param {string} method - HTTP method (GET, POST, PUT, DELETE)
* @param {string} path - Request path (e.g., /api/v1/wallets)
* @param {object|null} body - Request body (null for GET/DELETE)
* @param {string} apiKey - Your API key (sk_live_xxx)
* @param {string} apiSecret - Your API secret
* @returns {Promise<object>} Parsed JSON response
*/
async function apiRequest(method, path, body, apiKey, apiSecret) {
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = crypto.randomUUID();

// Step 1: Body hash
const bodyStr = body ? JSON.stringify(body) : '';
const bodyHash = crypto.createHash('sha256').update(bodyStr).digest('hex');

// Step 2: Canonical message
const canonical = [method, path, timestamp, nonce, bodyHash].join('\n');

// Step 3: HMAC-SHA256 signature
const signature = crypto
.createHmac('sha256', apiSecret)
.update(canonical)
.digest('base64');

// Send request
const res = await fetch(`https://api.lampay.app${path}`, {
method,
headers: {
'Content-Type': 'application/json',
'X-Api-Key': apiKey,
'X-Signature': signature,
'X-Timestamp': timestamp,
'X-Nonce': nonce,
},
body: bodyStr || undefined,
});

return res.json();
}

// ─── Usage examples ──────────────────────────────────────────

// POST: Create a transfer
const transfer = await apiRequest(
'POST',
'/api/v1/transfer/command/create',
{
sourceWalletId: 'w_123',
targetWalletId: 'w_456',
amount: '100.00',
currency: 'USD',
},
'sk_live_abc123def456',
'your-api-secret-here'
);

// GET: List wallets (no body)
const wallets = await apiRequest(
'GET',
'/api/v1/wallets',
null,
'sk_live_abc123def456',
'your-api-secret-here'
);

Server Validation Pipeline

When the server receives an API request, it validates the request through a multi-step pipeline. A failure at any step short-circuits processing and returns the corresponding error code.

┌─────────────────────────────────────────────────────────────────┐
│ Validation Pipeline │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. API Key Lookup │
│ ├── Missing X-Api-Key header → GA2001 (401) │
│ └── Key not found → GA2011 (401) │
│ │
│ 2. Key Status Check │
│ └── Key disabled by administrator → GA2021 (403) │
│ │
│ 3. Workspace Resolution │
│ ├── Workspace not found → GA2032 (404) │
│ └── Not a workspace member → GA2034 (403) │
│ │
│ 4. Timestamp Validation │
│ └── |server_time - timestamp| > 60s → GA2013 (401) │
│ │
│ 5. Nonce Uniqueness │
│ └── Nonce already used → GA2013 (401) │
│ │
│ 6. HMAC Signature Verification │
│ └── Computed HMAC ≠ X-Signature → GA2012 (401) │
│ │
│ 7. IP Whitelist (optional) │
│ └── Client IP not in whitelist → GA2022 (403) │
│ │
│ 8. Scope Check │
│ └── Missing required scope → GA2024 (403) │
│ │
│ ✓ All checks passed → process request │
└─────────────────────────────────────────────────────────────────┘
Validation order matters

The pipeline validates in a fixed order. For example, if your API key is valid but your timestamp is stale, you will receive GA2013 (timestamp expired) rather than a signature error. Use the error code to diagnose exactly which step failed.


API Scopes

API keys are workspace-scoped — each key belongs to a single workspace and can only access resources within that workspace. Cross-workspace access is not possible via API keys.

Each API key is assigned one or more scopes that control which endpoints it can access:

ScopeDescription
wallet:readQuery wallets within the workspace
transfer:readQuery transfer records within the workspace
transfer:createCreate transfer orders between wallets in the workspace

Accessible Endpoints

ScopeMethodEndpointDescription
wallet:readGET/api/v1/walletsList wallets
wallet:readGET/api/v1/wallets/{id}Get wallet details
wallet:readGET/api/v1/wallets/{id}/balanceGet wallet balance
wallet:readGET/api/v1/wallets/{id}/flowsList wallet flows
transfer:readGET/api/v1/transfer/query/ordersList transfer orders
transfer:readGET/api/v1/transfer/query/orders/wallet/{walletId}List transfers by wallet
transfer:readGET/api/v1/transfer/query/orders/{bizId}Get transfer order
transfer:readGET/api/v1/transfer/query/orders/{bizId}/completedCheck transfer completion
transfer:createPOST/api/v1/transfer/command/createCreate transfer order

Endpoints without a declared API scope are not accessible via the API chain (deny-by-default). Requests to such endpoints, or requests missing the required scope, will receive a 403 Forbidden response with error code 50090201 (API_ACCESS_DENIED).

Workspace Isolation

All API responses are automatically scoped to the workspace bound to the API key. Transfer query endpoints enforce workspace ownership validation — if a transfer order belongs to a different workspace, the request returns an error. This prevents cross-workspace data leakage even when using valid order IDs.


Example Responses

A successful API response:

200Successful transfer
{
"version": "1.3.0",
"timestamp": 1709337600000,
"success": true,
"code": "2000",
"message": "SUCCESS",
"data": {
"transferId": "txn_789xyz",
"status": "COMPLETED",
"amount": "100.00",
"currency": "USD"
}
}

An authentication failure:

401Signature verification failed
{
"version": "1.3.0",
"timestamp": 1709337600000,
"success": false,
"code": "GA2012",
"message": "SIGNATURE_INVALID",
"data": null
}

Common Pitfalls

Timestamp clock drift

The server rejects requests where the timestamp differs from server time by more than 60 seconds. If you see GA2013 errors, check that your server's clock is synchronized via NTP. When debugging locally, compare date +%s on your machine against an NTP server.

Body hash for empty bodies

GET and DELETE requests have no body. You must still include a body hash in the canonical message -- use the SHA-256 of an empty string: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855. Omitting it or using a different value will cause GA2012.

JSON serialization consistency

The body hash covers the exact bytes of the request body. If you compute the hash over one JSON string but your HTTP library re-serializes the body (changing key order, spacing, or encoding), the signature will not match. Best practice: serialize once, hash that string, and send that same string as the body.

Nonce reuse

Each nonce must be globally unique within the 60-second timestamp window. Reusing a nonce (even with a different request body) results in GA2013. Use UUID v4 to guarantee uniqueness.

Next Steps