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 NameTrigger ConditionRecommended Merchant Action
transaction.successCustomer transfer is fully confirmed on-chain and credited to your merchant ledger.Mark order as PAID; deliver digital goods or dispatch inventory.
transaction.expiredThe 15-minute rate lock window elapsed without payment receipt.Mark order as EXPIRED; prompt customer to create a fresh checkout quote.
transaction.invalidAn 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.testSynthetic 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:

HeaderDescription & Format
X-Hashpay-EventThe event identifier (e.g., transaction.success).
X-Hashpay-TimestampUnix epoch timestamp in seconds when the webhook was generated.
X-Hashpay-SignatureHMAC-SHA256 signature in the format sha256=<hex_digest>.
X-Hashpay-Signature-VersionSignature version identifier (v2).
X-Hashpay-Idempotency-KeyUnique 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 200 or 204 status 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_no and X-Hashpay-Idempotency-Key in 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.