Webhooks are used to notify the merchant system in real time about transaction status changes (such as deposits credited, payouts completed, etc.). When an event occurs, NUSDpay sends an HTTP POST request to the endpoint URL configured by the merchant.

How It Works

ItemDescription
Trigger conditionAutomatically triggered when transaction status changes (e.g. deposit confirmed, payout completed)
Request methodHTTP POST
Timeout2 seconds
Retry policyAutomatically retries if a 200/201 response is not received, up to 10 times
Response requirementRespond with 200 or 201 to indicate the event was successfully received
After receiving a Webhook, first verify that the wallet_id belongs to your project. Events from other projects should be ignored.

Configure Webhook

1

Prepare an endpoint URL

Deploy an HTTP endpoint on your server to receive Webhook events.
2

Provide the URL to us

Send the endpoint URL to technical support to be configured.
3

Implement the handling logic

In your endpoint, implement: parse the request → verify the signature → process the business logic → respond with 200/201.

Verify the Signature

To prevent unauthorized access, you must verify the signature when you receive a Webhook event.

Steps

  1. Read the timestamp and signature from the request headers:
bizTimestamp := r.Header.Get("biz-timestamp")
signature := r.Header.Get("biz-resp-signature")
  1. Concatenate the message: raw request body + "|" + timestamp
  2. Apply a double SHA256 hash to the message
  3. Verify the signature using the Ed25519 public key

Full Verification Code

package webhook

import (
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"golang.org/x/crypto/ed25519"
)

func VerifySignature(rawBody string, bizTimestamp string, signatureHex string, pubKeyHex string) (bool, error) {
	if bizTimestamp == "" || signatureHex == "" {
		return false, errors.New("missing signature or timestamp")
	}

	msg := rawBody + "|" + bizTimestamp

	h1 := sha256.Sum256([]byte(msg))
	h2 := sha256.Sum256(h1[:])
	msgHash := h2[:]

	sig, err := hex.DecodeString(signatureHex)
	if err != nil {
		return false, err
	}

	pubKeyBytes, err := hex.DecodeString(pubKeyHex)
	if err != nil {
		return false, err
	}

	if len(pubKeyBytes) != ed25519.PublicKeySize {
		return false, errors.New("invalid pubkey length")
	}
	if len(sig) != ed25519.SignatureSize {
		return false, errors.New("invalid signature length")
	}

	ok := ed25519.Verify(ed25519.PublicKey(pubKeyBytes), msgHash, sig)
	return ok, nil
}
Please contact technical support to obtain the PUB_KEY.

Webhook Events

Event Types

EventDescription
wallets.transaction.createdTransaction has been created
wallets.transaction.updatedTransaction status has been updated
wallets.transaction.succeededTransaction completed successfully
NUSDpay does not guarantee that events arrive in the order they are produced. Your endpoint should process business logic based on event content, not arrival order.

Retry Mechanism

  • Timeout (no response within 2 seconds) or non-200/201 response: the system retries automatically
  • Up to 10 retries
  • After exceeding the retry limit, the event status becomes “delivery failed”
We recommend using request_id for idempotency to prevent retries from causing duplicate processing. We also recommend periodically calling the query endpoint as a reconciliation fallback.

Complete Implementation Examples

package main

import (
	"encoding/json"
	"io"
	"log"
	"net/http"
	"time"
	"crypto/sha256"
	"encoding/hex"
	"golang.org/x/crypto/ed25519"
)

const (
	PORT    = "8000"
	PUB_KEY = "YOUR_PUB_KEY_HEX"
)

var walletID = "YOUR_WALLET_ID"

type WebhookPayload struct {
	Data struct {
		WalletID string `json:"wallet_id"`
	} `json:"data"`
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
	rawBody, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "Read body failed", http.StatusBadRequest)
		return
	}
	defer r.Body.Close()

	// Verify the signature
	signature := r.Header.Get("biz-resp-signature")
	bizTimestamp := r.Header.Get("biz-timestamp")
	message := string(rawBody) + "|" + bizTimestamp
	h1 := sha256.Sum256([]byte(message))
	h2 := sha256.Sum256(h1[:])

	sig, _ := hex.DecodeString(signature)
	pubKey, _ := hex.DecodeString(PUB_KEY)

	if !ed25519.Verify(ed25519.PublicKey(pubKey), h2[:], sig) {
		http.Error(w, "Invalid signature", http.StatusUnauthorized)
		return
	}

	// Parse and handle
	var payload WebhookPayload
	json.Unmarshal(rawBody, &payload)

	if payload.Data.WalletID == walletID {
		// Process business logic
		log.Println("Received event for wallet:", walletID)
	}

	w.WriteHeader(http.StatusCreated)
}

func main() {
	http.HandleFunc("/api/webhook", webhookHandler)
	log.Printf("Server running on :%s\n", PORT)
	log.Fatal((&http.Server{
		Addr: ":" + PORT, Handler: nil,
		ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second,
	}).ListenAndServe())
}
const express = require("express");
const CryptoJS = require("crypto-js");
const nacl = require("tweetnacl");

const PUB_KEY = "YOUR_PUB_KEY_HEX";
const WALLET_ID = "YOUR_WALLET_ID";

const app = express();
app.use(express.json({
    verify: (req, res, buf) => { req.rawBody = buf.toString(); }
}));

app.post("/api/webhook", (req, res) => {
    // Verify the signature
    const signature = req.headers['biz-resp-signature'];
    const bizTimestamp = req.headers['biz-timestamp'];
    const message = `${req.rawBody}|${bizTimestamp}`;
    const hash = CryptoJS.SHA256(CryptoJS.SHA256(message)).toString(CryptoJS.enc.Hex);

    try {
        const sigBuf = Buffer.from(signature, "hex");
        const msgBuf = Buffer.from(hash, "hex");
        const vk = Buffer.from(PUB_KEY, "hex");
        if (!nacl.sign.detached.verify(msgBuf, sigBuf, vk)) {
            return res.status(401).send("Invalid signature");
        }
    } catch (e) {
        return res.status(401).send("Verification failed");
    }

    // Process business logic
    const data = JSON.parse(req.rawBody);
    if (data.data.wallet_id === WALLET_ID) {
        console.log("Received event for wallet:", WALLET_ID);
    }

    res.sendStatus(201);
});

app.listen(8000, () => console.log("Server running on :8000"));

Next Steps

Deposit Callback

View the payload field reference for deposit events.

Withdrawal Callback

View the payload field reference for withdrawal events.