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 RequireReadAuth in 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

  1. 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.
  2. 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.
  3. Generate a fresh X-Request-ID per request. Reusing one is what causes 409 duplicate_request.
  4. Use HTTPS only. All production traffic must be TLS.
  5. Verify webhook signatures. See Webhooks.