webhook events & security
because payment transfers on the Faster Payment System (FPS) occur asynchronously, you must not rely on the client-side redirect to fulfill orders. instead, your server should listen for asynchronous webhook event callbacks dispatched by divit.
our gateway sends secure POST requests to your configured webhookEvents URL whenever the status of a payment order is updated.
event status catalog
divit dispatches a webhook for the following state transitions:
| Event ID | Title | Description | Recommended Action |
|---|---|---|---|
2001 | payment success | the customer has successfully completed the bank transfer, and the funds are secured. | fulfill the order: mark the order as paid, release goods/services, and notify the customer. |
4001 | payment expired | the order's expiration window (expiredAt) has passed, and the transaction has been closed without settlement. | release inventory: close the checkout session and return any reserved items back to your catalog. |
event payload structure
webhook payloads are delivered as an unauthenticated POST request containing a standardized JSON payload structure:
{
"event": {
"eventId": 2001,
"eventDescription": "Order payment completed successfully"
},
"eventData": {
"OrderID": "87418689-8f26-4200-8d6e-8c4430b41759",
"OrderAmount": {
"amount": 12050,
"currency": "HKD"
},
"MerchantRef": "ORDER-10024A"
}
}
properties reference
event.eventId(integer): the specific event status code (2001for success,4001for expired).eventData.OrderID(string): divit's unique identifier for the payment order. use this UUID to lookup the corresponding order in your database.eventData.OrderAmount(object): structured object containing the transaction value (amountin cents/smallest subunit, andcurrency).eventData.MerchantRef(string): the custom reference string you supplied when initiating the payment order.
cryptographic signature verification (HMAC-SHA256)
to prevent spoofing and fraudulent notifications, you must cryptographically validate every incoming webhook request's signature before executing any state changes or order fulfillment.
every legitimate webhook request from divit includes a custom signature header:
X-DIVIT-SIGNATURE: t=1683611281,s1=7ceJBJKe3TpZY55u3NxQldIDtL7LdcujDPFM7B53Bgw=
signature parameters
t: unix timestamp (seconds) of when the webhook was generated by our gateway. use this to prevent replay attacks by checking if the timestamp is recent (e.g., within 5 minutes).s1: the Base64 encoded HMAC-SHA256 signature, generated by hashing the payload content.
signature verification algorithm
to verify the payload signature, reconstruct the expected signature locally and compare it to the s1 signature parameter:
- extract parameters: parse the
X-DIVIT-SIGNATUREheader. split the header by commas (,), then split by equals (=) to extract the values fortands1. - reconstruct content string: concatenate the timestamp
t, a literal period character (.), and the raw, unparsed JSON request body.signatureContent = t + "." + rawRequestBody - generate local hash: compute an HMAC-SHA256 signature of the
signatureContentusing your local secretDIVIT_SIGNATURE_KEYas the cryptographic key. - encode and compare: convert the computed hash to a Base64-encoded string. perform a constant-time string comparison to compare your computed string against the extracted
s1signature. if they match exactly, the payload is verified.
reusable verification implementations
select an implementation template below for your backend stack:
- Node.js (Express)
- Python (FastAPI / Flask)
- Go
const crypto = require('crypto');
/**
* Verifies a cryptographically-signed Divit Webhook payload
* @param {string} headerVal - The X-DIVIT-SIGNATURE header value
* @param {string} rawBody - The raw, unparsed request body string
* @param {string} signatureKey - Your local merchant webhook signature key (secret)
* @returns {boolean} - True if signature is valid
*/
function verifyDivitSignature(headerVal, rawBody, signatureKey) {
if (!headerVal || !rawBody || !signatureKey) {
return false;
}
// Extract t and s1 parameters
const params = {};
headerVal.split(',').forEach(item => {
const [key, value] = item.trim().split('=');
params[key] = value;
});
const timestamp = params['t'];
const expectedSignature = params['s1'];
if (!timestamp || !expectedSignature) {
return false;
}
// Recreate the signature content: t.rawBody
const signatureContent = `${timestamp}.${rawBody}`;
// Compute the HMAC SHA256 signature
const calculatedSignature = crypto
.createHmac('sha256', signatureKey)
.update(signatureContent)
.digest('base64');
// Secure constant-time comparison
try {
return crypto.timingSafeEqual(
Buffer.from(calculatedSignature),
Buffer.from(expectedSignature)
);
} catch (e) {
return calculatedSignature === expectedSignature;
}
}
import hmac
import hashlib
import base64
def verify_divit_signature(header_val: str, raw_body_bytes: bytes, signature_key: str) -> bool:
"""
Verifies a cryptographically-signed Divit Webhook payload
:param header_val: The X-DIVIT-SIGNATURE header value
:param raw_body_bytes: The raw, unparsed request body as bytes
:param signature_key: Your local merchant webhook signature key (secret)
:returns: True if signature is valid
"""
if not header_val or not raw_body_bytes or not signature_key:
return False
try:
# Parse the t and s1 components from the signature header
parts = {item.split('=')[0].strip(): item.split('=')[1].strip() for item in header_val.split(',')}
timestamp = parts.get('t')
expected_sig = parts.get('s1')
if not timestamp or not expected_sig:
return False
# Recreate signature content: b"t.rawBody"
sig_content = f"{timestamp}.".encode('utf-8') + raw_body_bytes
# Compute HMAC SHA256
calculated_sig = base64.b64encode(
hmac.new(signature_key.encode('utf-8'), sig_content, hashlib.sha256).digest()
).decode('utf-8')
# Secure comparison
return hmac.compare_digest(calculated_sig, expected_sig)
except Exception:
return False
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"strings"
)
// VerifyDivitSignature validates incoming webhook signature
func VerifyDivitSignature(headerVal string, rawBody []byte, signatureKey string) bool {
if headerVal == "" || len(rawBody) == 0 || signatureKey == "" {
return false
}
// Parse parameters (t=..., s1=...)
var timestamp, expectedSignature string
parts := strings.Split(headerVal, ",")
for _, part := range parts {
kv := strings.Split(strings.TrimSpace(part), "=")
if len(kv) == 2 {
if kv[0] == "t" {
timestamp = kv[1]
} else if kv[0] == "s1" {
expectedSignature = kv[1]
}
}
}
if timestamp == "" || expectedSignature == "" {
return false
}
// Reconstruct signature content: t.rawBody
signatureContent := timestamp + "." + string(rawBody)
// Compute HMAC-SHA256 signature
mac := hmac.New(sha256.New, []byte(signatureKey))
mac.Write([]byte(signatureContent))
calculatedSignature := base64.StdEncoding.EncodeToString(mac.Sum(nil))
// Constant-time secure comparison
return hmac.Equal([]byte(calculatedSignature), []byte(expectedSignature))
}