Callback Signature Verification

How to create and verify HMAC-SHA256 signatures for secure webhook callbacks.

HMAC Callback Signature Verification

This guide explains how to securely verify incoming webhook or callback requests using HMAC-SHA256.

HMAC ensures:

  • The request came from a trusted source
  • The payload was not modified in transit
  • The request is protected against timing attacks
  • Replay attacks are mitigated using timestamps

Note:
Merchants can find their Webhook Secret in the Dashboard → Webhooks section.
This secret is required to verify callback signatures.
Keep it secure and never expose it in frontend code or public repositories.

Callback Headers

When Star Pay sends a callback request to your server, the HTTP request includes the following required headers.

{
  "headers": {
    "X-Signature": "79eb81c3d69395f5261dca9bc5f10079d54c49f8f40b1fc79f63635f3dbec3b8",
    "X-Timestamp": "1770748190504"
  }
}

Verification Flow

When receiving a callback:

  1. Extract X-Timestamp from the request headers.
  2. Extract X-Signature from the request headers.
  3. Recompute the expected signature:

How Signature Generation Works

The signature is generated using:

HMAC_SHA256(secret, ${timestamp}.${JSON.stringify(payload)})

The timestamp is included in the message to prevent replay attacks.


 import crypto from "crypto";
 
/**
 * Create HMAC-SHA256 signature for a payload
 * Matches the signature used when sending the callback
 */
export function createSignature(
  payload: unknown,
  secret: string,
  timestamp: string
): string {
  const body = JSON.stringify(payload);
  const message = `${timestamp}.${body}`; // include timestamp to prevent replay
  return crypto.createHmac("sha256", secret).update(message).digest("hex");
}
 
/**
 * Verify incoming callback signature
 * @param payload - JSON payload received
 * @param timestamp - X-Timestamp header from request
 * @param signature - X-Signature header from request
 * @param secret - Merchant's callback secret
 */
export function verifySignature(
  payload: unknown,
  timestamp: string,
  signature: string,
  secret: string
): boolean {
  const expectedSignature = createSignature(payload, secret, timestamp);
 
  const expectedBuffer = Buffer.from(expectedSignature, "hex");
  const signatureBuffer = Buffer.from(signature, "hex");
 
  if (expectedBuffer.length !== signatureBuffer.length) return false;
 
  // Timing-safe comparison to prevent timing attacks
  return crypto.timingSafeEqual(expectedBuffer, signatureBuffer);
}

Required Headers

When receiving a callback request, extract:

X-Timestamp X-Signature


Example Usage in Express

import express from "express";
import { verifySignature } from "./signature";
 
const app = express();
app.use(express.json());
 
app.post("/callback", (req, res) => {
  const timestamp = req.header("X-Timestamp") as string;
  const signature = req.header("X-Signature") as string;
 
  if (!timestamp || !signature) {
    return res.status(400).json({ message: "Missing headers" });
  }
 
  const isValid = verifySignature(
    req.body,
    timestamp,
    signature,
    process.env.CALLBACK_SECRET as string
  );
 
  if (!isValid) {
    return res.status(401).json({ message: "Invalid signature" });
  }
 
  // Valid callback, process payload here
  console.log("Payload received:", req.body);
 
  res.status(200).json({ message: "Callback verified successfully" });
});
 
app.listen(3000, () => console.log("Server running on http://localhost:3000"));

Security Notes

  • Always use crypto.timingSafeEqual to prevent timing attacks.
  • Reject requests with missing headers.
  • Optionally validate that the timestamp is within an acceptable time window (e.g., ±5 minutes).
  • Never expose your callback secret publicly.

Summary

Feature Purpose


HMAC-SHA256 Ensures data integrity Timestamp Prevents replay attacks timingSafeEqual Prevents timing attacks Shared Secret Authenticates sender

On this page