Authentication
ZoPay uses API key authentication with HMAC-SHA256 signature verification for secure API access. This guide explains how to authenticate your requests.
API Key Authentication
All /api/v1/* endpoints require authentication using your API key and a cryptographic signature. This ensures that only authorized requests are processed.
Required Headers
Every authenticated request must include the following headers:
1x-zo-key: <your-api-key>
2x-zo-timestamp: <unix-timestamp-in-seconds>
3x-zo-nonce: <unique-random-string>
4x-zo-origin: <your-domain-or-ip>
5x-zo-signature: <hmac-sha256-signature>
6x-zo-version: 1.0
7Content-Type: application/jsonHeader Descriptions
| Header | Description |
|---|---|
x-zo-key | Your public API key from the dashboard |
x-zo-timestamp | Unix timestamp in seconds (prevents replay attacks) |
x-zo-nonce | Unique random string for each request (prevents replay attacks) |
x-zo-origin | Your domain or IP address (for allowlisting) |
x-zo-signature | HMAC-SHA256 signature of the request |
x-zo-version | API version (currently "1.0") |
Generating the Signature
The signature is a HMAC-SHA256 hash of a string built from your request details. The format must match exactly - even small differences will cause authentication failures.
Critical: Common Mistakes That Cause Authentication Failures
- NO separators: The signature string must be concatenated WITHOUT any separators (no newlines, spaces, or special characters)
- NO prefix: The signature header should contain ONLY the hexadecimal hash, not
sha256=or any other prefix - Query sorting: Query parameters must be sorted alphabetically and included in the signature
- Consistent body: Body must be stringified consistently (canonical JSON) for both signature and request
Step 1: Build the String to Sign
Construct the string by concatenating all components directly without any separators:
1stringToSign = METHOD + PATH + SORTED_QUERY_PARAMS + REQUEST_BODY + TIMESTAMP + NONCE + ORIGINImportant: All components are concatenated directly - no newlines, no spaces, no separators. The order must be exactly as shown above.
Step 2: Generate HMAC-SHA256 Signature
Generate the signature using HMAC-SHA256 and return only the hexadecimal hash (no prefix):
1signature = HMAC-SHA256(secretKey, stringToSign)
2// Returns: abc123def456... (just hex, no prefix)Important: The signature header should contain only the hex string. Do NOT add sha256= or any other prefix.
JavaScript Example
1const crypto = require('crypto');
2
3function generateSignature(method, path, query, body, timestamp, nonce, origin, secretKey) {
4 // Sort query parameters alphabetically
5 const sortedQuery = Object.keys(query || {})
6 .sort()
7 .map(k => `${k}=${query[k]}`)
8 .join('&');
9
10 // Stringify body (canonical JSON - no extra spaces)
11 const bodyStr = body ? JSON.stringify(body) : '';
12
13 // Construct string to sign: METHOD + PATH + QUERY + BODY + TIMESTAMP + NONCE + ORIGIN
14 // NO separators, NO newlines - just concatenated
15 const stringToSign = `${method}${path}${sortedQuery}${bodyStr}${timestamp}${nonce}${origin}`;
16
17 // Generate HMAC-SHA256 signature
18 const signature = crypto
19 .createHmac('sha256', secretKey)
20 .update(stringToSign)
21 .digest('hex');
22
23 // Return just the hex string, no prefix
24 return signature;
25}
26
27function generateHeaders(method, path, body, query, apiKey, secretKey, origin) {
28 const timestamp = Math.floor(Date.now() / 1000).toString();
29 const nonce = crypto.randomUUID();
30 const signature = generateSignature(method, path, query, body, timestamp, nonce, origin, secretKey);
31
32 return {
33 'x-zo-key': apiKey,
34 'x-zo-timestamp': timestamp,
35 'x-zo-nonce': nonce,
36 'x-zo-origin': origin,
37 'x-zo-signature': signature, // Just hex, no prefix
38 'x-zo-version': '1.0',
39 'Content-Type': 'application/json'
40 };
41}
42
43// Usage
44const headers = generateHeaders(
45 'POST',
46 '/api/v1/wallets/quote',
47 { amount: '1000', currency: 'XAF' }, // Body as object
48 {}, // Query parameters (empty object if none)
49 process.env.ZITOPAY_API_KEY,
50 process.env.ZITOPAY_SECRET_KEY,
51 'https://yourdomain.com'
52);Python Example
1import hmac
2import hashlib
3import time
4import secrets
5import json
6from urllib.parse import urlencode
7
8def generate_signature(method, path, query, body, timestamp, nonce, origin, secret_key):
9 # Sort query parameters alphabetically
10 sorted_query = ''
11 if query:
12 sorted_items = sorted(query.items())
13 sorted_query = urlencode(sorted_items)
14
15 # Stringify body (canonical JSON - no extra spaces)
16 body_str = json.dumps(body) if body else ''
17
18 # Construct string to sign: METHOD + PATH + QUERY + BODY + TIMESTAMP + NONCE + ORIGIN
19 # NO separators, NO newlines - just concatenated
20 string_to_sign = f"{method}{path}{sorted_query}{body_str}{timestamp}{nonce}{origin}"
21
22 # Generate HMAC-SHA256 signature
23 signature = hmac.new(
24 secret_key.encode('utf-8'),
25 string_to_sign.encode('utf-8'),
26 hashlib.sha256
27 ).hexdigest()
28
29 # Return just the hex string, no prefix
30 return signature
31
32def generate_headers(method, path, body, query, api_key, secret_key, origin):
33 timestamp = str(int(time.time()))
34 nonce = secrets.token_hex(16)
35 signature = generate_signature(method, path, query, body, timestamp, nonce, origin, secret_key)
36
37 return {
38 'x-zo-key': api_key,
39 'x-zo-timestamp': timestamp,
40 'x-zo-nonce': nonce,
41 'x-zo-origin': origin,
42 'x-zo-signature': signature, # Just hex, no prefix
43 'x-zo-version': '1.0',
44 'Content-Type': 'application/json'
45 }
46
47# Usage
48headers = generate_headers(
49 'POST',
50 '/api/v1/wallets/quote',
51 {'amount': '1000', 'currency': 'XAF'}, # Body as dict
52 {}, # Query parameters (empty dict if none)
53 os.environ['ZITOPAY_API_KEY'],
54 os.environ['ZITOPAY_SECRET_KEY'],
55 'https://yourdomain.com'
56)Common Issues and Solutions
Issue: "Invalid signature" or "Merchant not found" errors
If you're getting authentication errors even with correct API keys, check these:
- Signature string format: Ensure NO separators (no newlines, spaces, or special characters) between components
- Signature header format: Must be just the hex string, no
sha256=prefix - Query parameters: Must be sorted alphabetically and included in signature
- Body formatting: Use canonical JSON (same string for signature and request body)
- Component order: Must be exactly: METHOD + PATH + QUERY + BODY + TIMESTAMP + NONCE + ORIGIN
Debugging Tips
- Log the exact
stringToSignvalue and compare character-by-character - Verify the signature is just hex (no prefix) in the header
- Ensure query parameters are sorted alphabetically
- Check that body JSON is stringified consistently
- Verify timestamp is in seconds (not milliseconds)
- Ensure nonce is unique for each request
Security Best Practices
- Never expose your secret key: Keep it server-side only
- Use HTTPS: Always use HTTPS in production
- Validate timestamps: Reject requests with timestamps too far in the past/future
- Use unique nonces: Never reuse nonces to prevent replay attacks
- Rotate keys regularly: Change your API keys periodically
- IP Whitelisting: Configure IP allowlists in production
- Test in sandbox first: Always test signature generation in sandbox before production
Public Endpoints
Some endpoints don't require authentication:
/public/v1/*- Public endpoints (no authentication)
IAM-Protected Routes
Non-API routes (dashboard, admin, etc.) use JWT token authentication:
1Authorization: Bearer <jwt-token>Error Responses
Authentication failures return 401 Unauthorized:
1{
2 "error": "Unauthorized",
3 "message": "Invalid signature",
4 "code": "AUTH_ERROR"
5}