Webhooks API

Receive real-time HTTP POST notifications when deposits, withdrawals, or sweeps reach a new state.

Authentication: Dual-auth (JWT or API Key + HMAC) on every route. Base path: /api/v1/webhooks (plus org-wide /api/v1/deliveries).


Supported events

Event Fired when
deposit.pending Deposit detected on-chain, waiting for confirmations
deposit.confirmed Deposit reached required confirmations
withdrawal.initiated Withdrawal queued for broadcast
withdrawal.completed Withdrawal confirmed on-chain
withdrawal.failed Withdrawal failed (reverted, dropped, or expired)
sweep.initiated Sweep transaction broadcast (child → master)
sweep.completed Sweep confirmed on-chain
sweep.failed Sweep failed (reverted, dropped, or expired)

Wildcard subscriptions ("events": ["*"]) are not supported — subscribe to each event by name. The runtime list is also available at GET /api/v1/webhooks/events.

webhook.test is a synthetic event delivered only by POST /webhooks/:id/test. It is never queued, retried, or counted as a real event.


Create subscription

POST /api/v1/webhooks

Request body

Field Type Required Description
url string Yes HTTPS endpoint to deliver to
events string[] Yes One or more event names from the table above

Example

curl -X POST https://api.hasapay.com/api/v1/webhooks \
  -H "Authorization: Bearer <jwt>" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-server.com/webhooks/hasapay",
    "events": ["deposit.confirmed", "withdrawal.completed", "withdrawal.failed"]
  }'

Response

{
  "data": {
    "id": "990e8400-e29b-41d4-a716-446655440004",
    "url": "https://your-server.com/webhooks/hasapay",
    "events": ["deposit.confirmed", "withdrawal.completed", "withdrawal.failed"],
    "is_active": true,
    "created_at": "2026-06-11T10:00:00Z",
    "updated_at": "2026-06-11T10:00:00Z"
  },
  "secret": "whk_8f3a2c1d9b7e4f6a0c5d8e2b1f4a7c9d3e6b8a1c4f7d0e3a6b9c2d5f8e1a4b7c",
  "message": "Store this secret now — it will not be shown again. Use it to verify the X-HasaPay-Signature header on every delivery."
}

The secret is returned exactly once. Store it immediately. If you lose it, rotate via POST /webhooks/:id/secret/regenerate and update your verifier with the new value.

Each subscription has its own secret — two subscriptions on the same org sign with different secrets.


List subscriptions

GET /api/v1/webhooks

Returns every subscription on the organization. Secrets are never included.


Get subscription

GET /api/v1/webhooks/:id

Update subscription

PUT /api/v1/webhooks/:id

Request body

Field Type Description
url string New delivery URL
events string[] Full replacement list (not a delta)
is_active boolean Pause (false) or resume (true) deliveries without deleting

Send only the fields you want to change.


Delete subscription

DELETE /api/v1/webhooks/:id

Regenerate subscription secret

POST /api/v1/webhooks/:id/secret/regenerate

Mints a fresh secret, persists it encrypted, and returns it once. In-flight retries continue with the old secret until their retry budget exhausts — there is no dual-signature grace window, so plan rotations during low-volume hours.

Response

{
  "data": {
    "id": "990e8400-e29b-41d4-a716-446655440004",
    "url": "https://your-server.com/webhooks/hasapay",
    "events": ["deposit.confirmed", "withdrawal.completed"],
    "is_active": true,
    "created_at": "2026-06-11T10:00:00Z",
    "updated_at": "2026-06-11T11:00:00Z"
  },
  "secret": "whk_<new_64_char_secret>",
  "message": "Store it now — it will not be shown again."
}

Send a test delivery

POST /api/v1/webhooks/:id/test

Synthesises a webhook.test event signed with the subscription's real secret and delivers it synchronously. Useful for verifying signature math, endpoint reachability, and response capture without waiting for a real transaction.

Test deliveries:

  • Have MaxAttempts = 1 — they are not retried.
  • Carry "is_test": true on the delivery row.
  • Are hidden from GET /webhooks/:id/deliveries and GET /deliveries by default (pass ?include_tests=true to include).
  • Never carry a data.fees block.

Response

{
  "data": {
    "delivery_id": "f4a1b2c3-d4e5-6789-abcd-ef0123456789",
    "status": "success",
    "http_status_code": 200,
    "response_body": "ok",
    "response_headers": { "x-request-id": "abc123" },
    "attempts": 1
  }
}

List available events

GET /api/v1/webhooks/events

Returns the 8 event names above with descriptions. Use this to build a UI that lets users pick events.


Per-subscription deliveries

List

GET /api/v1/webhooks/:id/deliveries?status=&limit=50&include_tests=false
Param Description
status Filter: pending, success, failed, retrying
limit Page size (default 50)
include_tests true to include webhook.test rows (default false)

Get one

GET /api/v1/webhooks/:id/deliveries/:deliveryId

Returns the full delivery row including the signed payload, response status code, response body, and the allow-listed response headers we captured (CF-Ray, X-Request-Id, X-Trace-Id, X-Amzn-Trace-Id, X-Vercel-Id, Server, Via).

Retry

POST /api/v1/webhooks/:id/deliveries/:deliveryId/retry

Requeues the delivery for another attempt. Only meaningful for deliveries currently in failed or retrying state.


Org-wide delivery log

GET /api/v1/deliveries?status=&limit=50&include_tests=false

Same filters as the per-subscription list, but returns deliveries across every subscription on the org. Convenient for a single "webhook activity" view.


Delivery payload

Every event ships the same envelope. The data block is identical in shape across deposit, withdrawal, and sweep — only field values change.

Headers

Header Value
Content-Type application/json
User-Agent HasaPay-Webhook/1.0
X-HasaPay-Event The event name, e.g. deposit.confirmed
X-HasaPay-Delivery UUID unique to this delivery attempt's row (use for per-attempt logging)
X-HasaPay-Signature t=<unix_seconds>,v1=<hex_hmac_sha256> — see Verifying signatures

There is no separate X-HasaPay-Timestamp header — the timestamp lives inside X-HasaPay-Signature as t=....

Envelope

{
  "event": "deposit.confirmed",
  "event_id": "5c8b3f2a-1d4e-5a6b-8c9d-0e1f2a3b4c5d",
  "timestamp": "2026-06-11T12:00:00Z",
  "data": { /* WebhookTransactionPayload, see below */ }
}

event_id is a stable UUIDv5 derived from (transaction_id, event). The same value is delivered to every subscription listening to that event and is identical across retries — use it as your idempotency key. Per-attempt identity lives in the X-HasaPay-Delivery header.

data shape

Field Type Notes
transaction_id string (UUID) Always present
type string deposit / withdrawal / sweep
status string pending / confirmed / failed
tx_hash string Empty until broadcast
chain string ethereum / polygon / tron / solana / bitcoin
network string mainnet / sepolia / amoy / shasta / devnet / testnet
from object { address, master_wallet_id, child_address_id } — IDs set only when we own that side
to object Same shape as from
amount string Token base units (no decimals). Authoritative for math. String to avoid float precision loss on 18-decimal tokens
amount_formatted string Decimal-applied display string, e.g. "100.000000"
token_symbol string USDC, ETH, TRX, etc.
token_address string|null Contract address (EVM/Tron) or mint address (Solana). Null for native
token_decimals int 6 for USDC, 18 for ETH, 9 for SOL, etc.
block_number int64 Block height / Solana slot
confirmations int Current confirmation count
required_confirmations int Threshold the event uses to flip to confirmed
gas_used string On-chain gas / energy / bandwidth consumed (base units)
gas_price string Wei / SUN / lamport per unit
failure_reason string reverted, expired, etc. — populated on *.failed events only
created_at string RFC3339
broadcasted_at string RFC3339, omitted until broadcast
confirmed_at string RFC3339, omitted until confirmed
fees object|null Fee roll-up — populated only on deposit.* and withdrawal.* (see below). Always null on sweep.* and webhook.test

ID placement on from / to

Type from to
deposit external sender — both IDs null one ID set (whichever side we own)
withdrawal one ID set; both set on routed-child withdrawals (master + child) external receiver — both IDs null
sweep child_address_id set master_wallet_id set

fees block

Populated on deposit.pending, deposit.confirmed, withdrawal.initiated, withdrawal.completed, withdrawal.failed. Each line item carries both the raw base-unit string and a formatted display string.

{
  "platform_fee":  { "amount": "1000", "amount_formatted": "0.001000", "amount_usd": 0.001, "token_symbol": "USDC", "token_decimals": 6 },
  "org_fee":       { "amount": "500",  "amount_formatted": "0.000500", "amount_usd": 0.0005, "token_symbol": "USDC", "token_decimals": 6 },
  "rev_share":     { "amount": "200",  "amount_formatted": "0.000200", "amount_usd": 0.0002, "token_symbol": "USDC", "token_decimals": 6 },
  "total_fee":     { "amount": "1500", "amount_formatted": "0.001500", "amount_usd": 0.0015, "token_symbol": "USDC", "token_decimals": 6 },
  "reversed":      false
}
  • platform_fee — HasaPay's cut, debited from the org's wallet.
  • org_fee — your own fee charged on top, debited from the same wallet.
  • rev_share — revenue share HasaPay credits back to the org.
  • total_fee — platform_fee + org_fee (the gross debit).
  • reversed — true on withdrawal.failed once ledger reversal entries were written. Treat fees as zeroed for reconciliation.

Computing net amount yourself (intentionally not surfaced because semantics differ by direction):

  • Deposit net to your wallet: amount − total_fee.amount + rev_share.amount
  • Withdrawal debit from your wallet: amount + total_fee.amount

Sample payloads

deposit.confirmed

{
  "event": "deposit.confirmed",
  "event_id": "5c8b3f2a-1d4e-5a6b-8c9d-0e1f2a3b4c5d",
  "timestamp": "2026-06-11T12:00:00Z",
  "data": {
    "transaction_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "type": "deposit",
    "status": "confirmed",
    "tx_hash": "0xabc123def456789012345678901234567890abcdef1234567890abcdef123456",
    "chain": "ethereum",
    "network": "sepolia",
    "from": {
      "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb7",
      "master_wallet_id": null,
      "child_address_id": null
    },
    "to": {
      "address": "0xd502b72b8D969D60D1094174b1457C73671eb9d8",
      "master_wallet_id": "f912eb59-a2b4-46e4-a3a0-8d6b34f542fe",
      "child_address_id": "7c1e9a3b-4d2f-5e6a-8b9c-0d1e2f3a4b5c"
    },
    "amount": "100000000",
    "amount_formatted": "100.000000",
    "token_symbol": "USDC",
    "token_address": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238",
    "token_decimals": 6,
    "block_number": 6234567,
    "confirmations": 12,
    "required_confirmations": 12,
    "gas_used": "65000",
    "gas_price": "20000000000",
    "created_at": "2026-06-11T11:55:00Z",
    "broadcasted_at": "2026-06-11T11:55:30Z",
    "confirmed_at": "2026-06-11T12:00:00Z",
    "fees": {
      "platform_fee":  { "amount": "1000000", "amount_formatted": "1.000000", "amount_usd": 1.00, "token_symbol": "USDC", "token_decimals": 6 },
      "org_fee":       { "amount": "500000",  "amount_formatted": "0.500000", "amount_usd": 0.50, "token_symbol": "USDC", "token_decimals": 6 },
      "rev_share":     { "amount": "200000",  "amount_formatted": "0.200000", "amount_usd": 0.20, "token_symbol": "USDC", "token_decimals": 6 },
      "total_fee":     { "amount": "1500000", "amount_formatted": "1.500000", "amount_usd": 1.50, "token_symbol": "USDC", "token_decimals": 6 },
      "reversed":      false
    }
  }
}

For deposit.pending, the same payload arrives with "status": "pending", confirmations < required_confirmations, and no confirmed_at.

withdrawal.completed

{
  "event": "withdrawal.completed",
  "event_id": "8d2e4a1c-3b5f-6a7d-9e0f-1a2b3c4d5e6f",
  "timestamp": "2026-06-11T13:00:00Z",
  "data": {
    "transaction_id": "b2c3d4e5-f6a7-8901-bcde-f23456789012",
    "type": "withdrawal",
    "status": "confirmed",
    "tx_hash": "0xdef789abc012345678901234567890abcdef1234567890abcdef1234567890ab",
    "chain": "ethereum",
    "network": "sepolia",
    "from": {
      "address": "0xd502b72b8D969D60D1094174b1457C73671eb9d8",
      "master_wallet_id": "f912eb59-a2b4-46e4-a3a0-8d6b34f542fe",
      "child_address_id": null
    },
    "to": {
      "address": "0x987654321abcdef0123456789abcdef012345678",
      "master_wallet_id": null,
      "child_address_id": null
    },
    "amount": "50000000",
    "amount_formatted": "50.000000",
    "token_symbol": "USDC",
    "token_address": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238",
    "token_decimals": 6,
    "block_number": 6234600,
    "confirmations": 12,
    "required_confirmations": 12,
    "gas_used": "65000",
    "gas_price": "22000000000",
    "created_at": "2026-06-11T12:55:00Z",
    "broadcasted_at": "2026-06-11T12:55:15Z",
    "confirmed_at": "2026-06-11T13:00:00Z",
    "fees": {
      "platform_fee":  { "amount": "500000",  "amount_formatted": "0.500000", "amount_usd": 0.50, "token_symbol": "USDC", "token_decimals": 6 },
      "org_fee":       { "amount": "250000",  "amount_formatted": "0.250000", "amount_usd": 0.25, "token_symbol": "USDC", "token_decimals": 6 },
      "rev_share":     { "amount": "100000",  "amount_formatted": "0.100000", "amount_usd": 0.10, "token_symbol": "USDC", "token_decimals": 6 },
      "total_fee":     { "amount": "750000",  "amount_formatted": "0.750000", "amount_usd": 0.75, "token_symbol": "USDC", "token_decimals": 6 },
      "reversed":      false
    }
  }
}

Variants of the same event:

  • withdrawal.initiated — "status": "pending", no confirmed_at, tx_hash may be empty if not yet broadcast.
  • withdrawal.failed — "status": "failed", failure_reason set ("reverted", "dropped", "expired"), fees.reversed: true once ledger reversal entries are written.

sweep.completed

{
  "event": "sweep.completed",
  "event_id": "1f3a5b7c-9d8e-7f6a-5b4c-3d2e1f0a9b8c",
  "timestamp": "2026-06-11T14:00:00Z",
  "data": {
    "transaction_id": "c3d4e5f6-a7b8-9012-cdef-345678901234",
    "type": "sweep",
    "status": "confirmed",
    "tx_hash": "0x111222333444555666777888999aaabbbcccdddeeefff0001112223334445556",
    "chain": "ethereum",
    "network": "sepolia",
    "from": {
      "address": "0x9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b",
      "master_wallet_id": null,
      "child_address_id": "7c1e9a3b-4d2f-5e6a-8b9c-0d1e2f3a4b5c"
    },
    "to": {
      "address": "0xd502b72b8D969D60D1094174b1457C73671eb9d8",
      "master_wallet_id": "f912eb59-a2b4-46e4-a3a0-8d6b34f542fe",
      "child_address_id": null
    },
    "amount": "98500000",
    "amount_formatted": "98.500000",
    "token_symbol": "USDC",
    "token_address": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238",
    "token_decimals": 6,
    "block_number": 6234650,
    "confirmations": 12,
    "required_confirmations": 12,
    "gas_used": "85000",
    "gas_price": "20000000000",
    "created_at": "2026-06-11T13:55:00Z",
    "broadcasted_at": "2026-06-11T13:55:30Z",
    "confirmed_at": "2026-06-11T14:00:00Z",
    "fees": null
  }
}

sweep.* never carries a fees block — sweep cost is network gas, conveyed in gas_used × gas_price, not a HasaPay fee.

Variants:

  • sweep.initiated — "status": "pending", no confirmed_at.
  • sweep.failed — "status": "failed", failure_reason set.

webhook.test

{
  "event": "webhook.test",
  "event_id": "00000000-0000-5000-8000-000000000000",
  "timestamp": "2026-06-11T15:00:00Z",
  "data": {
    "transaction_id": "00000000-0000-0000-0000-000000000000",
    "type": "deposit",
    "status": "confirmed",
    "tx_hash": "0xtest",
    "chain": "ethereum",
    "network": "sepolia",
    "from": { "address": "0xtest_sender", "master_wallet_id": null, "child_address_id": null },
    "to":   { "address": "0xtest_receiver", "master_wallet_id": null, "child_address_id": null },
    "amount": "1000000",
    "amount_formatted": "1.000000",
    "token_symbol": "USDC",
    "token_decimals": 6,
    "confirmations": 12,
    "required_confirmations": 12,
    "status": "confirmed",
    "fees": null
  }
}

Test deliveries are signed with the subscription's real secret, so signature verification logic exercises the production path end-to-end.


Verifying signatures

The header is Stripe-style:

X-HasaPay-Signature: t=1718107200,v1=8f3a2c1d9b7e4f6a0c5d8e2b1f4a7c9d3e6b8a1c4f7d0e3a6b9c2d5f8e1a4b7c

To verify:

  1. Parse t (unix seconds) and v1 (hex HMAC) from the header.
  2. Build the signed string: <t>.<raw_body> — the raw request body bytes, exactly as received, before any JSON parsing.
  3. Compute HMAC-SHA256(secret, signed_string) and hex-encode it.
  4. Constant-time compare against v1.
  5. Reject if t differs from your server's clock by more than ~5 minutes — protects against replay of a captured payload.

Node.js (Express)

const crypto = require('crypto');
const express = require('express');
const app = express();

const WEBHOOK_SECRET = process.env.HASAPAY_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;

// Capture raw body for signature verification — must run before any JSON parser
app.use('/webhooks/hasapay', express.raw({ type: 'application/json' }));

app.post('/webhooks/hasapay', (req, res) => {
  const header = req.headers['x-hasapay-signature'] || '';
  const parts = Object.fromEntries(header.split(',').map(kv => kv.split('=')));
  const t = parseInt(parts.t, 10);
  const v1 = parts.v1;

  if (!t || !v1) return res.status(400).send('bad signature header');
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > TOLERANCE_SECONDS) {
    return res.status(401).send('timestamp out of tolerance');
  }

  const signed = Buffer.concat([Buffer.from(`${t}.`), req.body]);
  const expected = crypto.createHmac('sha256', WEBHOOK_SECRET).update(signed).digest('hex');

  const sigBuf = Buffer.from(v1, 'hex');
  const expBuf = Buffer.from(expected, 'hex');
  if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
    return res.status(401).send('invalid signature');
  }

  const payload = JSON.parse(req.body.toString());
  switch (payload.event) {
    case 'deposit.confirmed':     handleDeposit(payload.data); break;
    case 'withdrawal.completed':  handleWithdrawal(payload.data); break;
  }
  res.status(200).send('ok');
});

Python (Flask)

import hmac, hashlib, os, time
from flask import Flask, request

app = Flask(__name__)
WEBHOOK_SECRET = os.environ['HASAPAY_WEBHOOK_SECRET'].encode()
TOLERANCE_SECONDS = 300

@app.route('/webhooks/hasapay', methods=['POST'])
def webhook():
    header = request.headers.get('X-HasaPay-Signature', '')
    parts = dict(kv.split('=', 1) for kv in header.split(',') if '=' in kv)
    try:
        t = int(parts['t'])
        v1 = parts['v1']
    except (KeyError, ValueError):
        return 'bad signature header', 400

    if abs(int(time.time()) - t) > TOLERANCE_SECONDS:
        return 'timestamp out of tolerance', 401

    raw = request.get_data()
    signed = f"{t}.".encode() + raw
    expected = hmac.new(WEBHOOK_SECRET, signed, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(v1, expected):
        return 'invalid signature', 401

    payload = request.get_json()
    if payload['event'] == 'deposit.confirmed':
        handle_deposit(payload['data'])
    return 'ok', 200

Go

package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

var secret = []byte(os.Getenv("HASAPAY_WEBHOOK_SECRET"))

func handle(w http.ResponseWriter, r *http.Request) {
    header := r.Header.Get("X-HasaPay-Signature")
    var ts, v1 string
    for _, kv := range strings.Split(header, ",") {
        parts := strings.SplitN(kv, "=", 2)
        if len(parts) != 2 {
            continue
        }
        switch parts[0] {
        case "t":
            ts = parts[1]
        case "v1":
            v1 = parts[1]
        }
    }
    t, err := strconv.ParseInt(ts, 10, 64)
    if err != nil || v1 == "" {
        http.Error(w, "bad signature header", http.StatusBadRequest)
        return
    }
    if d := time.Now().Unix() - t; d > 300 || d < -300 {
        http.Error(w, "timestamp out of tolerance", http.StatusUnauthorized)
        return
    }

    body, _ := io.ReadAll(r.Body)
    mac := hmac.New(sha256.New, secret)
    mac.Write([]byte(ts + "."))
    mac.Write(body)
    expected := hex.EncodeToString(mac.Sum(nil))

    if !hmac.Equal([]byte(v1), []byte(expected)) {
        http.Error(w, "invalid signature", http.StatusUnauthorized)
        return
    }

    // ...dispatch on payload.event...
    w.WriteHeader(http.StatusOK)
    w.Write([]byte("ok"))
}

Retry policy

A delivery succeeds when your endpoint returns a 2xx status within 10 seconds.

Backoff schedule

Attempt Delay before retry
1 1 minute
2 5 minutes
3 15 minutes
4 1 hour
5 6 hours
6 24 hours
7 24 hours
8 24 hours

Total window: ~79 hours across 8 attempts. After the last attempt, the delivery is marked failed and visible in the delivery log; you can manually requeue via POST /webhooks/:id/deliveries/:deliveryId/retry.

Failure classification

Response Outcome
2xx Success — stop retrying
410 Gone Subscription deactivated (is_active = false) — we treat 410 as "stop sending forever"
4xx (other than 408, 429) Terminal — marked failed immediately, retry budget not consumed
408 Request Timeout, 429 Too Many Requests Retried per schedule
5xx Retried per schedule
Network error, DNS failure, TLS handshake error, 10s timeout Retried per schedule

The reason for terminating early on most 4xx codes is that they signal a permanent client problem (wrong path, malformed handler, auth misconfig). Burning all 8 retries on a 404 wastes our queue and your error logs.

Pause vs delete

Use PUT /webhooks/:id with "is_active": false to pause delivery without losing the subscription's history. Set it back to true to resume.


Best practices

  1. Respond fast. Return 200 first, process asynchronously. Slow handlers risk hitting the 10s timeout and burning retry budget.
  2. Verify signatures every time. Reject anything missing or failing X-HasaPay-Signature verification.
  3. Deduplicate by event_id. It's stable across retries and across multiple subscriptions on the same event — perfect dedupe key. Use a database unique constraint or a short-lived cache.
  4. Use HTTPS. Plaintext URLs are rejected by the create endpoint.
  5. Subscribe narrowly. Don't subscribe to events you won't act on — every event is delivered, logged, and counted.
  6. Rotate secrets during low-volume hours. Regeneration has no dual-signature grace window; in-flight retries with the old secret continue until their retry budget exhausts.
  7. Treat amount as the source of truth. It's the raw base-unit string. amount_formatted is for display only — round-tripping it back to raw will lose precision on 18-decimal tokens.

Errors

Code Cause
WEBHOOK_NOT_FOUND No subscription with that ID on this organization
INVALID_URL URL is not a valid HTTPS endpoint
INVALID_EVENTS One or more event names not in the supported list (or "*" was passed — wildcards are no longer supported)
WEBHOOK_LIMIT_REACHED Max subscriptions reached for the plan
DELIVERY_NOT_FOUND Delivery ID does not belong to this subscription
SECRET_NOT_SET Subscription has no signing secret (rare — call /secret/regenerate to mint one)