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
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
| Component | Description | Example |
|---|---|---|
method | HTTP method, uppercase | POST |
path | Request path only -- no scheme, host, or query string | /api/v1/transfers |
timestamp | Unix epoch seconds (UTC), same value as the X-Timestamp header | 1709337600 |
nonce | Unique identifier, same value as the X-Nonce header (UUID v4 recommended) | 550e8400-e29b-41d4-a716-446655440000 |
bodyHash | SHA-256 hex digest from Step 1 | a1fce436... |
For a POST /api/v1/transfers request, the canonical message would look like:
POST
/api/v1/transfers
1709337600
550e8400-e29b-41d4-a716-446655440000
a1fce4363854ff888cff4b8e7875d600c...
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 │
└─────────────────────────────────────────────────────────────────┘
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:
| Scope | Description |
|---|---|
wallet:read | Query wallets within the workspace |
transfer:read | Query transfer records within the workspace |
transfer:create | Create transfer orders between wallets in the workspace |
Accessible Endpoints
| Scope | Method | Endpoint | Description |
|---|---|---|---|
wallet:read | GET | /api/v1/wallets | List wallets |
wallet:read | GET | /api/v1/wallets/{id} | Get wallet details |
wallet:read | GET | /api/v1/wallets/{id}/balance | Get wallet balance |
wallet:read | GET | /api/v1/wallets/{id}/flows | List wallet flows |
transfer:read | GET | /api/v1/transfer/query/orders | List transfer orders |
transfer:read | GET | /api/v1/transfer/query/orders/wallet/{walletId} | List transfers by wallet |
transfer:read | GET | /api/v1/transfer/query/orders/{bizId} | Get transfer order |
transfer:read | GET | /api/v1/transfer/query/orders/{bizId}/completed | Check transfer completion |
transfer:create | POST | /api/v1/transfer/command/create | Create 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:
An authentication failure:
Common Pitfalls
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.
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.
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.
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
- API Reference & Error Codes -- Full error code table, API key management, and technical specifications.
- Error Handling -- Response envelope format and the complete error code catalog across all domains.