Webhook Signature Verification
Every webhook carries an HMAC signature so you can prove it came from ZaroPay (and wasn't tampered with). Verify it before acting on any webhook.
The scheme
- Algorithm: HMAC-SHA256, output as lowercase hex (Stripe-style).
- Signing key: your endpoint's signing secret — the full
whsec_…string, used verbatim as the UTF-8 key. Do not hex/base64-decode it; thewhsec_prefix is part of the key. - Signed message:
`{t}.{body}`— the timestamp, a literal., then the raw request body bytes exactly as received (no JSON re-serialization, no whitespace changes). - Header:
x-zaropay-signature: t=<unixSeconds>,v1=<hex>
v1 = HMAC_SHA256(secret, "<t>.<rawBody>") → lowercase hex
header = "t=<t>,v1=<v1>"
Example header:
x-zaropay-signature: t=1719500000,v1=3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b
- Use the raw body. Read the bytes/string before your framework parses JSON. If you verify against a re-serialized object, the signature will never match.
- Compare in constant time. Use a constant-time comparison (
timingSafeEqual,hmac.compare_digest,hash_equals, …), never==. - The secret includes
whsec_. Pass the whole string as the key.
Verify in your language
Each function takes the raw body, the x-zaropay-signature header value, and your
whsec_… secret, and returns true only for an authentic, untampered request.
- Node.js
- Python
- PHP
- Java
- C# / .NET
- Go
const crypto = require("crypto");
function verifyZaroPaySignature(rawBody, signatureHeader, secret) {
if (typeof rawBody !== "string" || !signatureHeader || !secret) return false;
const parts = {};
for (const kv of signatureHeader.split(",")) {
const i = kv.indexOf("=");
if (i > 0) parts[kv.slice(0, i).trim()] = kv.slice(i + 1).trim();
}
const { t, v1 } = parts;
if (!t || !v1) return false;
const expected = crypto
.createHmac("sha256", secret) // secret used verbatim (whsec_...)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(v1, "hex");
const b = Buffer.from(expected, "hex");
if (a.length !== b.length) return false;
// Optional replay protection (ZaroPay does not enforce one):
// if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > 300) return false;
return crypto.timingSafeEqual(a, b);
}
Express receiver (note express.raw):
const express = require("express");
const app = express();
app.post("/webhooks/zaropay", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body.toString("utf8"); // raw bytes → string
if (!verifyZaroPaySignature(raw, req.get("x-zaropay-signature"), process.env.ZAROPAY_WEBHOOK_SECRET)) {
return res.status(400).send("invalid signature");
}
const event = JSON.parse(raw);
// dedupe on event.id, then handle event.event === "deposit.confirmed"
res.sendStatus(200);
});
import hashlib
import hmac
import time
def verify_zaropay_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
if not raw_body or not signature_header or not secret:
return False
parts = {}
for kv in signature_header.split(","):
i = kv.find("=")
if i > 0:
parts[kv[:i].strip()] = kv[i + 1:].strip()
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1:
return False
signed = f"{t}.".encode("utf-8") + raw_body # timestamp + "." + raw bytes
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
# Optional replay protection (not enforced by ZaroPay):
# if abs(int(time.time()) - int(t)) > 300: return False
return hmac.compare_digest(v1, expected) # constant-time
Flask receiver:
from flask import Flask, request, abort
import os
app = Flask(__name__)
@app.post("/webhooks/zaropay")
def zaropay_webhook():
raw = request.get_data() # raw bytes, before any parsing
if not verify_zaropay_signature(raw, request.headers.get("X-Zaropay-Signature", ""),
os.environ["ZAROPAY_WEBHOOK_SECRET"]):
abort(400)
event = request.get_json()
# dedupe on event["id"], handle event["event"] == "deposit.confirmed"
return "", 200
<?php
function zaropay_verify_signature(string $rawBody, string $signatureHeader, string $secret): bool {
if ($rawBody === '' || $signatureHeader === '' || $secret === '') return false;
$parts = [];
foreach (explode(',', $signatureHeader) as $kv) {
$i = strpos($kv, '=');
if ($i !== false && $i > 0) {
$parts[trim(substr($kv, 0, $i))] = trim(substr($kv, $i + 1));
}
}
if (empty($parts['t']) || empty($parts['v1'])) return false;
$expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret); // secret verbatim
// Optional replay protection (not enforced by ZaroPay):
// if (abs(time() - (int) $parts['t']) > 300) return false;
return hash_equals($expected, $parts['v1']); // constant-time
}
// Receiver (plain PHP / WordPress / Laravel — read php://input for the raw body):
$raw = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_ZAROPAY_SIGNATURE'] ?? '';
$secret = getenv('ZAROPAY_WEBHOOK_SECRET');
if (!zaropay_verify_signature($raw, $sig, $secret)) {
http_response_code(400);
exit('invalid signature');
}
$event = json_decode($raw, true);
// dedupe on $event['id'], handle $event['event'] === 'deposit.confirmed'
http_response_code(200);
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HashMap;
import java.util.Map;
public final class ZaroPayWebhooks {
public static boolean verify(String rawBody, String signatureHeader, String secret) throws Exception {
if (rawBody == null || signatureHeader == null || secret == null) return false;
Map<String, String> parts = new HashMap<>();
for (String kv : signatureHeader.split(",")) {
int i = kv.indexOf('=');
if (i > 0) parts.put(kv.substring(0, i).trim(), kv.substring(i + 1).trim());
}
String t = parts.get("t"), v1 = parts.get("v1");
if (t == null || v1 == null) return false;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] expected = mac.doFinal((t + "." + rawBody).getBytes(StandardCharsets.UTF_8));
byte[] got = hexDecode(v1);
// Optional replay protection (not enforced by ZaroPay):
// if (Math.abs(System.currentTimeMillis() / 1000 - Long.parseLong(t)) > 300) return false;
return MessageDigest.isEqual(expected, got); // constant-time
}
private static byte[] hexDecode(String s) {
int n = s.length();
byte[] out = new byte[n / 2];
for (int i = 0; i < n; i += 2) {
out[i / 2] = (byte) Integer.parseInt(s.substring(i, i + 2), 16);
}
return out;
}
}
In Spring, read the raw body with a
@RequestBody byte[] body(orContentCachingRequestWrapper) so you sign the exact bytes — don't sign a deserialized object.
using System;
using System.Security.Cryptography;
using System.Text;
public static class ZaroPayWebhooks
{
public static bool Verify(string rawBody, string signatureHeader, string secret)
{
if (string.IsNullOrEmpty(rawBody) || string.IsNullOrEmpty(signatureHeader) || string.IsNullOrEmpty(secret))
return false;
string? t = null, v1 = null;
foreach (var kv in signatureHeader.Split(','))
{
var i = kv.IndexOf('=');
if (i > 0)
{
var key = kv[..i].Trim();
var val = kv[(i + 1)..].Trim();
if (key == "t") t = val;
else if (key == "v1") v1 = val;
}
}
if (t is null || v1 is null) return false;
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expected = hmac.ComputeHash(Encoding.UTF8.GetBytes($"{t}.{rawBody}"));
byte[] got;
try { got = Convert.FromHexString(v1); } catch { return false; }
// Optional replay protection (not enforced by ZaroPay):
// if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - long.Parse(t)) > 300) return false;
return CryptographicOperations.FixedTimeEquals(expected, got); // constant-time
}
}
In ASP.NET Core, enable buffering (
HttpRequest.EnableBuffering()) or read the body stream to a string before model binding, so you verify the raw bytes.
package zaropay
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strings"
)
// Verify reports whether the webhook signature is authentic.
func Verify(rawBody []byte, signatureHeader, secret string) bool {
if len(rawBody) == 0 || signatureHeader == "" || secret == "" {
return false
}
var t, v1 string
for _, kv := range strings.Split(signatureHeader, ",") {
if i := strings.Index(kv, "="); i > 0 {
switch strings.TrimSpace(kv[:i]) {
case "t":
t = strings.TrimSpace(kv[i+1:])
case "v1":
v1 = strings.TrimSpace(kv[i+1:])
}
}
}
if t == "" || v1 == "" {
return false
}
mac := hmac.New(sha256.New, []byte(secret)) // secret verbatim
mac.Write([]byte(t + "." + string(rawBody)))
expected := mac.Sum(nil)
got, err := hex.DecodeString(v1)
if err != nil {
return false
}
// Optional replay protection (not enforced by ZaroPay):
// if math.Abs(float64(time.Now().Unix()-parseInt(t))) > 300 { return false }
return hmac.Equal(expected, got) // constant-time
}
Replay protection (optional)
ZaroPay does not enforce a freshness window — the t value is provided so you can add one if you
want. If you do, reject deliveries where abs(now - t) exceeds your tolerance (e.g. 5 minutes). Each
sample above shows the one-line check, commented out.
Testing your verifier
The signing is a plain HMAC, so you can generate a matching signature yourself to unit-test your receiver (Node example):
const crypto = require("crypto");
const secret = "whsec_test_secret";
const body = JSON.stringify({ id: "evt_1", event: "deposit.confirmed", data: {} });
const t = Math.floor(Date.now() / 1000);
const v1 = crypto.createHmac("sha256", secret).update(`${t}.${body}`).digest("hex");
const header = `t=${t},v1=${v1}`;
// POST `body` to your endpoint with header `x-zaropay-signature: ${header}`
You can also trigger a real signed delivery from the dashboard, or via
POST /v1/webhooks/endpoints/:id/test.
Checklist
- Read the raw body before JSON parsing.
- Parse
tandv1fromx-zaropay-signature. - HMAC-SHA256 over
`{t}.{rawBody}`with the fullwhsec_…secret. - Constant-time compare against
v1. - Return
2xxonly after verification passes. - Dedupe on the payload
id. - (Optional) enforce a replay window on
t.