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": trueon the delivery row. - Are hidden from
GET /webhooks/:id/deliveriesandGET /deliveriesby default (pass?include_tests=trueto include). - Never carry a
data.feesblock.
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—trueonwithdrawal.failedonce 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", noconfirmed_at,tx_hashmay be empty if not yet broadcast.withdrawal.failed—"status": "failed",failure_reasonset ("reverted","dropped","expired"),fees.reversed: trueonce 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", noconfirmed_at.sweep.failed—"status": "failed",failure_reasonset.
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:
- Parse
t(unix seconds) andv1(hex HMAC) from the header. - Build the signed string:
<t>.<raw_body>— the raw request body bytes, exactly as received, before any JSON parsing. - Compute
HMAC-SHA256(secret, signed_string)and hex-encode it. - Constant-time compare against
v1. - Reject if
tdiffers 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
- Respond fast. Return
200first, process asynchronously. Slow handlers risk hitting the 10s timeout and burning retry budget. - Verify signatures every time. Reject anything missing or failing
X-HasaPay-Signatureverification. - 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. - Use HTTPS. Plaintext URLs are rejected by the create endpoint.
- Subscribe narrowly. Don't subscribe to events you won't act on — every event is delivered, logged, and counted.
- 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.
- Treat
amountas the source of truth. It's the raw base-unit string.amount_formattedis 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) |