Authentication
HasaPay uses three authentication tiers. Picking the right one is mostly about reading the endpoint table at the bottom of this page — but the short version is:
| Tier | Header(s) | Used for |
|---|---|---|
| JWT | Authorization: Bearer <token> |
User/org/team management, API key management, settings, login flow |
| HMAC | X-API-Key, X-Signature, X-Timestamp, X-Request-ID |
Sensitive financial writes — wallet creation, send, address management |
| Dual-auth | Either JWT or HMAC | Every read endpoint and most config writes — wallets/addresses/transactions/balances/fees/sweep/stats/webhooks/assets |
If an endpoint accepts both, send whichever fits your caller — server-rendered dashboards use JWT, programmatic integrations use HMAC.
JWT Authentication
JWT (JSON Web Token) authenticates requests on behalf of a logged-in user.
Getting a JWT token
POST /api/v1/auth/login
Request:
{
"email": "you@company.com",
"password": "your_password"
}
Single-org user — response:
{
"message": "Login successful",
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": {
"id": "uuid",
"email": "you@company.com",
"full_name": "Your Name"
},
"organization": {
"id": "uuid",
"name": "Your Company",
"status": "active",
"email_verified": true
},
"role": "owner",
"pending_invites": []
}
}
Multi-org user — response:
{
"message": "Please select an organization",
"requires_org_selection": true,
"data": {
"session_token": "<short-lived token>",
"organizations": [
{ "id": "uuid", "name": "Org A", "role": "owner" }
],
"pending_invites": []
}
}
When requires_org_selection is true, follow up with POST /api/v1/auth/select-org (body: {session_token, organization_id}) to receive the final JWT.
Using the JWT token
Send it as a Bearer header:
curl https://api.hasapay.com/api/v1/team/me \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
HMAC Authentication
HMAC authenticates programmatic API key holders. The server reconstructs the signed payload from the request and compares against your X-Signature header.
Required headers
| Header | Description | Example |
|---|---|---|
X-API-Key |
Your API key (the public half) | WzKQ1n5L8bJ9c3VfXmnPq... (~44 chars) |
X-Signature |
Hex-encoded HMAC-SHA256 of the payload | a1b2c3... |
X-Timestamp |
Unix seconds at request time | 1713260400 |
X-Request-ID |
A fresh UUID per request | 550e8400-e29b-41d4-a716-446655440000 |
All four are required. Missing any of them returns 401 missing_headers.
The signed payload
{timestamp}:{requestId}:{body}
That's a literal colon-joined string. No newlines. {body} is the exact raw JSON bytes you're sending (or empty string for GET / no body). The HTTP method and path are not part of the payload — only timestamp, request ID, and body matter to the signature.
Example payload string (POST with body):
1713260400:550e8400-e29b-41d4-a716-446655440000:{"name":"Production Key","permissions":["wallet:read"],"environment":"production"}
Sign that string with your secret key (HMAC-SHA256, hex output) and send the result as X-Signature.
Timestamp window
The server accepts timestamps within ±300 seconds (5 minutes) of its own clock. Outside that window: 401 timestamp_expired. Generate the timestamp at request time; do not cache it.
Replay protection
(organization_id, X-Request-ID) is cached server-side for 2 × window (10 minutes). Reusing a request ID inside that window returns:
409 duplicate_request
So always mint a fresh UUID per request — don't reuse one across retries (a retry needs a new ID, and a new ID means a new signature because the request ID is part of the signed payload).
Code examples
Node.js
const crypto = require('crypto');
const { randomUUID } = require('crypto');
const axios = require('axios');
class HasaPayClient {
constructor(apiKey, secretKey, baseURL = 'https://api.hasapay.com') {
this.apiKey = apiKey;
this.secretKey = secretKey;
this.baseURL = baseURL;
}
sign(timestamp, requestId, body) {
const payload = `${timestamp}:${requestId}:${body}`;
return crypto
.createHmac('sha256', this.secretKey)
.update(payload)
.digest('hex');
}
async request(method, path, body = null) {
const timestamp = Math.floor(Date.now() / 1000).toString();
const requestId = randomUUID();
const bodyString = body ? JSON.stringify(body) : '';
const signature = this.sign(timestamp, requestId, bodyString);
const response = await axios({
method,
url: `${this.baseURL}${path}`,
headers: {
'Content-Type': 'application/json',
'X-API-Key': this.apiKey,
'X-Signature': signature,
'X-Timestamp': timestamp,
'X-Request-ID': requestId,
},
data: body,
});
return response.data;
}
}
// Usage
const client = new HasaPayClient('your_api_key', 'your_secret_key');
const wallets = await client.request('GET', '/api/v1/wallets');
Python
import hmac
import hashlib
import json
import time
import uuid
import requests
class HasaPayClient:
def __init__(self, api_key, secret_key, base_url='https://api.hasapay.com'):
self.api_key = api_key
self.secret_key = secret_key
self.base_url = base_url
def sign(self, timestamp, request_id, body):
payload = f'{timestamp}:{request_id}:{body}'
return hmac.new(
self.secret_key.encode(),
payload.encode(),
hashlib.sha256,
).hexdigest()
def request(self, method, path, body=None):
timestamp = str(int(time.time()))
request_id = str(uuid.uuid4())
body_string = json.dumps(body, separators=(',', ':')) if body else ''
signature = self.sign(timestamp, request_id, body_string)
return requests.request(
method=method,
url=f'{self.base_url}{path}',
headers={
'Content-Type': 'application/json',
'X-API-Key': self.api_key,
'X-Signature': signature,
'X-Timestamp': timestamp,
'X-Request-ID': request_id,
},
data=body_string if body else None,
).json()
# Usage
client = HasaPayClient('your_api_key', 'your_secret_key')
wallets = client.request('GET', '/api/v1/wallets')
Go
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"time"
"github.com/google/uuid"
)
func sign(secretKey, timestamp, requestID, body string) string {
payload := fmt.Sprintf("%s:%s:%s", timestamp, requestID, body)
mac := hmac.New(sha256.New, []byte(secretKey))
mac.Write([]byte(payload))
return hex.EncodeToString(mac.Sum(nil))
}
// Usage
timestamp := fmt.Sprintf("%d", time.Now().Unix())
requestID := uuid.NewString()
body := `{"chain":"ethereum","network":"sepolia"}`
signature := sign("your_secret_key", timestamp, requestID, body)
Important Python note: json.dumps(body, separators=(',', ':')) produces no whitespace. The bytes you send on the wire and the bytes you sign must be byte-identical — if you json.dumps(body) (with default separators) for signing but the request library re-serializes with different separators, the signature won't match. Easiest fix: serialize once into a string, sign that string, send that string as the body.
Test vs Live API keys
Every API key is bound to an environment, and that environment fixes the mode of every request the key makes:
| Environment | Prefix | Mode | Reaches |
|---|---|---|---|
sandbox |
tk_… |
Test | testnet networks only |
production |
pk_… |
Live | mainnet networks only |
A sandbox key can never touch mainnet and a production key can never touch testnet — enforced server-side, header or no header. production keys can only be created once your organization is approved + KYC-verified. See Live & Test Mode for the full model.
Dual-auth endpoints
A dual-auth endpoint accepts either a JWT Bearer token or the four HMAC headers. The server figures it out from which headers are present.
Use this when:
- Building a dashboard or backend that already has a JWT in hand — just send
Authorization: Bearer ... - Building a server-to-server integration — sign with HMAC
You don't need to do anything special to "opt in" to dual-auth. Pick a tier and send those headers; that's it.
Common HMAC errors
| Status / code | Cause | Fix |
|---|---|---|
401 missing_headers |
One of the four required HMAC headers is absent | Send all of X-API-Key, X-Signature, X-Timestamp, X-Request-ID |
401 invalid_timestamp |
X-Timestamp is not a parseable integer |
Send Unix seconds (not milliseconds, not ISO 8601) |
401 timestamp_expired |
X-Timestamp is more than 300 seconds away from server time |
Check system clock; generate timestamp at request time |
401 invalid_api_key |
API key is wrong, revoked, or doesn't exist | Confirm the key string + that it's active |
401 invalid_signature |
Signature didn't match | Most common: body bytes signed ≠ body bytes sent. Sign a string, send that exact string. |
409 duplicate_request |
Same (org, X-Request-ID) was used inside the 10-minute replay window |
Mint a fresh UUID per request, including retries |
403 ORG_SUSPENDED |
The organization is suspended | Blocked on every request (JWT and HMAC) until reinstated — contact support |
403 MODE_MISMATCH |
Targeted a network outside the key's mode (e.g. a sandbox key acting on mainnet) |
Use a production key for mainnet, sandbox for testnet |
403 LIVE_MODE_LOCKED |
Requested live mode before the org is approved | Complete approval + KYC; until then you're test-only |
Endpoint reference — which tier does each route use?
"Dual-auth" means
RequireReadAuthin the backend — JWT or HMAC both work.
JWT-only
| Routes | Notes |
|---|---|
/auth/switch-org, /auth/my-organizations, /auth/invite/:token/accept-existing, /auth/invite/:token/decline, /auth/pending-invites, /auth/change-password |
Authenticated auth-flow routes |
/team/* (all) |
Member + invite management |
/settings/organization (GET, PUT) |
Org profile |
/api-keys/* (all) |
API key CRUD |
HMAC-only (sensitive financial writes)
| Routes |
|---|
POST /wallets |
POST /wallets/:walletId/address |
PUT /wallets/:walletId/addresses/:addressId |
PUT /wallets/:walletId/addresses/:addressId/auto-sweep |
POST /wallets/:walletId/send |
POST /wallets/:walletId/addresses/:addressId/send |
POST /wallets/:walletId/addresses/:addressId/estimate-gas |
Dual-auth (JWT or HMAC)
| Category | Routes |
|---|---|
| Wallet reads | GET /wallets, GET /wallets/:walletId |
| Addresses reads | GET /addresses, GET /wallets/:walletId/addresses[/:addressId] |
| Balances | GET /wallets/:walletId/balance, GET /wallets/:walletId/balances |
| Transactions reads | GET /transactions, GET /transactions/:id, GET /transactions/:id/status, GET /transactions/hash/:hash |
| Assets | GET /assets/supported, GET /assets, POST /assets/enable, DELETE /assets/:asset_id |
| Webhooks | All /webhooks/* and /deliveries routes (incl. POST/PUT/DELETE + retry) |
| Stats | GET /stats, GET /stats/volume, GET /stats/chains |
| Fees | All /fees/* routes (config, address overrides, estimate, deposit estimate, summary, history, sources) |
| Sweep | All /sweep/* routes (config, per-chain overrides, addresses, history, manual trigger) |
Public (no auth)
| Routes |
|---|
POST /auth/register, /auth/verify-email, /auth/resend-code |
POST /auth/login, /auth/select-org |
POST /auth/forgot-password, GET/POST /auth/reset-password/:token |
GET /auth/invite/:token, POST /auth/invite/:token/accept |
Security best practices
- Never expose your secret key. It's server-side only — the only time you ever see it is the create-key response. Lose it, you have to rotate.
- Sign the bytes you send. Don't sign a pretty-printed JSON and send a minified one (or vice versa). Easiest invariant: serialize once into a string, sign that, send that.
- Generate a fresh
X-Request-IDper request. Reusing one is what causes409 duplicate_request. - Use HTTPS only. All production traffic must be TLS.
- Verify webhook signatures. See Webhooks.