Webhooks
Hashpay delivers signed HTTP POST notifications to your webhook URL whenever a transaction transitions to a terminal state. Webhooks are the authoritative source of truth for payment reconciliation.
Webhook event types
Hashpay dispatches real-time event payloads corresponding to critical lifecycle milestones of customer payments:
| Event Name | Trigger Condition | Recommended Merchant Action |
|---|---|---|
transaction.success | Customer transfer is fully confirmed on-chain and credited to your merchant ledger. | Mark order as PAID; deliver digital goods or dispatch inventory. |
transaction.expired | The 15-minute rate lock window elapsed without payment receipt. | Mark order as EXPIRED; prompt customer to create a fresh checkout quote. |
transaction.invalid | An on-chain transfer was detected but failed validation (e.g., underpayment, wrong token, or unsupported network). | Log the transaction hash and flag the invoice for manual customer support review. |
webhook.test | Synthetic simulation triggered manually via Dashboard (Settings → API Keys → Send Test Event). | Verify that your signature checking and HTTP 200 response handler work smoothly. |
HTTP headers sent with every webhook
Every outbound webhook delivery includes standard cryptographic and routing headers:
| Header | Description & Format |
|---|---|
X-Hashpay-Event | The event identifier (e.g., transaction.success). |
X-Hashpay-Timestamp | Unix epoch timestamp in seconds when the webhook was generated. |
X-Hashpay-Signature | HMAC-SHA256 signature in the format sha256=<hex_digest>. |
X-Hashpay-Signature-Version | Signature version identifier (v2). |
X-Hashpay-Idempotency-Key | Unique idempotency key (derived from transaction hash or delivery attempt) to safely deduplicate retried events. |
JSON event payloads
Here are exact payload examples for each event type:
1. transaction.success Payload
{
"event": "transaction.success",
"data": {
"order_no": "INV-2026-0001",
"merchant_id": "10001",
"status": "Success",
"token": "USDT",
"network": "BNB Chain",
"paid_amount": "9.200000",
"final_amount": "9.154000",
"tx_hash": "0x3a7e28b14c995f088127394c8b66da982341209b0a1d48293c834017834bcdef",
"link_explorer": "https://bscscan.com/tx/0x3a7e28b14c995f088127394c8b66da982341209b0a1d48293c834017834bcdef",
"manual_review": false
}
}2. transaction.expired Payload
{
"event": "transaction.expired",
"data": {
"order_no": "INV-2026-0002",
"merchant_id": "10001",
"status": "Expired",
"expired_at": 1821388756
}
}3. transaction.invalid Payload
{
"event": "transaction.invalid",
"data": {
"order_no": "INV-2026-0003",
"merchant_id": "10001",
"status": "Invalid",
"token": "USDT",
"network": "BNB Chain",
"paid_amount": "5.000000",
"tx_hash": "0x89ab12cd34ef567890123456789abcdef0123456789abcdef0123456789abcde",
"link_explorer": "https://bscscan.com/tx/0x89ab...",
"invalid_message": "Underpayment detected: expected 9.200000 USDT but received 5.000000 USDT",
"manual_review": true
}
}4. webhook.test Payload
{
"event": "webhook.test",
"test": true,
"message": "This is a test event from the Hashpay dashboard.",
"data": {
"order_no": "INV-TEST-9999",
"merchant_id": "10001",
"status": "Success"
}
}Verifying the webhook signature (HMAC-SHA256)
The signature is computed over the string <timestamp>.<raw_body> using your merchant Webhook Signing Secret as the HMAC key. Validate the signature in your server before parsing the JSON content:
Node.js / Express Signature Verification
import crypto from "node:crypto";
import { Request, Response } from "express";
export function verifyHashpayWebhook(req: Request, res: Response) {
const signature = req.headers["x-hashpay-signature"] as string;
const timestamp = req.headers["x-hashpay-timestamp"] as string;
const secret = process.env.HASHPAY_SIGNING_SECRET!;
// 1. Replay attack defense: reject timestamps older than 5 minutes (300 seconds)
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(age) || age > 300) {
return res.status(400).send("Expired or invalid timestamp");
}
// 2. Compute expected HMAC-SHA256 over timestamp + "." + raw body buffer
// Note: req.rawBody must contain the raw unmodified request Buffer
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(Buffer.concat([Buffer.from(timestamp + "."), req.body])) // or rawBody
.digest("hex");
// 3. Timing-safe comparison to prevent timing attacks
const sigBuffer = Buffer.from(signature ?? "");
const expBuffer = Buffer.from(expected);
const isValid = sigBuffer.length === expBuffer.length && crypto.timingSafeEqual(sigBuffer, expBuffer);
if (!isValid) {
return res.status(401).send("Invalid webhook signature");
}
// 4. Handle event
const payload = JSON.parse(req.body.toString());
if (payload.event === "transaction.success") {
console.log("Order paid successfully:", payload.data.order_no);
// Fulfill order in database...
}
// 5. Acknowledge with 200 OK immediately
return res.status(200).json({ received: true });
}PHP / Laravel Signature Verification
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class HashpayWebhookController extends Controller
{
public function handle(Request $request)
{
$signature = $request->header('X-Hashpay-Signature');
$timestamp = $request->header('X-Hashpay-Timestamp');
$secret = config('services.hashpay.signing_secret');
// 1. Reject timestamps older than 300 seconds
if (abs(time() - (int)$timestamp) > 300) {
return response()->json(['error' => 'Timestamp expired'], 400);
}
// 2. Compute expected HMAC
$rawBody = $request->getContent();
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
// 3. Timing-safe comparison
if (!hash_equals($expected, (string)$signature)) {
return response()->json(['error' => 'Invalid signature'], 401);
}
// 4. Process event
$payload = $request->json()->all();
if ($payload['event'] === 'transaction.success') {
$orderNo = $payload['data']['order_no'];
// Update order status in database...
}
return response()->json(['status' => 'success'], 200);
}
}Python (FastAPI / Flask) Signature Verification
import hmac
import hashlib
import time
def verify_hashpay_webhook(raw_body: bytes, timestamp_str: str, signature: str, secret: str) -> bool:
try:
timestamp = int(timestamp_str)
except (ValueError, TypeError):
return False
# 1. Anti-replay check (5 minute window)
if abs(time.time() - timestamp) > 300:
return False
# 2. Compute HMAC-SHA256
signed_payload = str(timestamp).encode("utf-8") + b"." + raw_body
expected_hex = hmac.new(
secret.encode("utf-8"),
signed_payload,
hashlib.sha256
).hexdigest()
expected_signature = f"sha256={expected_hex}"
# 3. Constant-time comparison
return hmac.compare_digest(expected_signature, signature or "")Delivery retries & idempotency best practices
- Immediate 200 OK Response — Return an HTTP
200or204status code within 3 seconds. Queue heavy tasks (such as inventory sync, email generation, or ERP integration) to a background worker. - Automatic Exponential Retries — If your server returns an HTTP 5xx or times out, Hashpay automatically retries delivery up to 3 times with exponential backoff delays.
- Idempotent Execution — Network blips may cause duplicate delivery of the same event. Record the
data.order_noandX-Hashpay-Idempotency-Keyin your database. If you have already processed the order, simply return HTTP 200 without duplicating fulfillment. - Never Re-serialize Raw JSON — Always compute HMAC over the raw, unparsed request bytes received from the network. Re-serializing parsed JSON can alter key order or whitespace and invalidate the signature.