Star-Pay sends real-time payment notifications to your server by POSTing a JSON payload to the callbackURL you specify when creating an order. To ensure these requests are authentic and unmodified, each callback is signed with HMAC-SHA256.
Overview
Provide a callbackURL when creating an order
Include the callbackURL field in your POST /trdp/order request body. This must be a publicly accessible HTTPS endpoint on your server.{
"callbackURL": "https://yoursite.com/payments/callback",
...
}
Receive the callback
After the customer completes (or fails) payment, Star-Pay POSTs the transaction result to your endpoint.
Verify the signature
Check the X-Signature header against the HMAC-SHA256 of the payload before trusting the data.
Process the payment
Once verified, update your database, fulfill the order, and return HTTP 200.
Callback payload
Star-Pay sends the following JSON body to your callbackURL.
Successful payment
{
"billRefNo": "33WJ8946WB",
"status": "PAID",
"timestamp": "2025-12-10T11:05:37.566Z",
"message": "Payment successful",
"merchantId": "6888dc21ee7cbfe63657144f",
"customerId": "656445e6-20fa-440d-b8c9-0a588d1ca05b",
"externalReferenceId": "CLA9SR5XR9",
"amount": 1000,
"payment_type": "USSD_PUSH",
"receipt_url": "https://receipt.starpayethiopia.com/receiptqa/WST-33WJ8946WB"
}
Failed payment
{
"billRefNo": "5I974ZLE60",
"status": "FAILED",
"message": "Payment failed"
}
Payload fields
| Field | Type | Description |
|---|
billRefNo | string | Star-Pay bill reference number |
status | string | PAID or FAILED |
timestamp | string | ISO 8601 time of transaction completion |
message | string | Human-readable result message |
merchantId | string | Your merchant identifier |
customerId | string | Customer identifier in Star-Pay’s system |
externalReferenceId | string | External reference for reconciliation |
amount | number | Amount charged |
payment_type | string | Payment method used, e.g. USSD_PUSH |
receipt_url | string | Link to the payment receipt |
Every callback request from Star-Pay includes these headers:
{
"X-Signature": "79eb81c3d69395f5261dca9bc5f10079d54c49f8f40b1fc79f63635f3dbec3b8",
"X-Timestamp": "1770748190504"
}
Your Webhook Secret is available in your merchant dashboard under Dashboard → Webhooks. This is a separate secret from your x-api-secret API key. Never expose it in frontend code or public repositories.
Signature verification
Algorithm
Star-Pay generates the signature as:
HMAC_SHA256(secret, "${timestamp}.${JSON.stringify(payload)}")
The timestamp is embedded in the signed message to prevent replay attacks.
To verify:
- Extract
X-Timestamp from the request headers.
- Extract
X-Signature from the request headers.
- Recompute
HMAC_SHA256(webhookSecret, "${X-Timestamp}.${JSON.stringify(body)}").
- Compare the computed signature to
X-Signature using a timing-safe comparison.
- Reject the request if they do not match.
Signature verification code
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);
}
import hmac
import hashlib
import json
def create_signature(payload, secret, timestamp):
body = json.dumps(payload, separators=(",", ":")) # consistent JSON
message = f"{timestamp}.{body}"
signature = hmac.new(
secret.encode("utf-8"),
message.encode("utf-8"),
hashlib.sha256
).hexdigest()
return signature
def verify_signature(payload, timestamp, signature, secret):
expected_signature = create_signature(payload, secret, timestamp)
return hmac.compare_digest(expected_signature, signature)
package signature
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
)
func CreateSignature(payload interface{}, secret string, timestamp string) (string, error) {
body, err := json.Marshal(payload)
if err != nil {
return "", err
}
message := timestamp + "." + string(body)
h := hmac.New(sha256.New, []byte(secret))
h.Write([]byte(message))
return hex.EncodeToString(h.Sum(nil)), nil
}
func VerifySignature(payload interface{}, timestamp, signature, secret string) (bool, error) {
expectedSignature, err := CreateSignature(payload, secret, timestamp)
if err != nil {
return false, err
}
expectedBytes, _ := hex.DecodeString(expectedSignature)
providedBytes, _ := hex.DecodeString(signature)
if len(expectedBytes) != len(providedBytes) {
return false, nil
}
return subtle.ConstantTimeCompare(expectedBytes, providedBytes) == 1, nil
}
Handling the callback endpoint
The examples below show a complete webhook handler that verifies the signature and processes the payload.
JavaScript (Express)
Python (FastAPI)
Go (Gin)
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 the payload
const { billRefNo, status, amount } = req.body;
if (status === "PAID") {
// Fulfill the order associated with billRefNo
console.log(`Payment received: ${billRefNo}, amount: ${amount}`);
}
res.status(200).json({ message: "Callback verified successfully" });
});
app.listen(3000, () => console.log("Server running on http://localhost:3000"));
from fastapi import FastAPI, Header, HTTPException, Request
from signature import verify_signature
app = FastAPI()
CALLBACK_SECRET = "your_callback_secret_here"
@app.post("/callback")
async def callback(
request: Request,
x_timestamp: str = Header(...),
x_signature: str = Header(...),
):
payload = await request.json()
if not verify_signature(payload, x_timestamp, x_signature, CALLBACK_SECRET):
raise HTTPException(status_code=401, detail="Invalid signature")
# Valid callback — process the payload
if payload.get("status") == "PAID":
bill_ref = payload.get("billRefNo")
amount = payload.get("amount")
# Fulfill the order associated with bill_ref
return {"message": "Callback verified successfully"}
import (
"github.com/gin-gonic/gin"
"net/http"
)
func CallbackHandler(c *gin.Context) {
var payload map[string]interface{}
if err := c.BindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Invalid JSON"})
return
}
timestamp := c.GetHeader("X-Timestamp")
signature := c.GetHeader("X-Signature")
secret := "your_callback_secret_here"
valid, err := VerifySignature(payload, timestamp, signature, secret)
if err != nil || !valid {
c.JSON(http.StatusUnauthorized, gin.H{"message": "Invalid signature"})
return
}
// Valid callback
c.JSON(http.StatusOK, gin.H{"message": "Callback verified successfully"})
}
Security notes
Always verify the HMAC signature before trusting or acting on a callback payload. An unverified callback could be forged by a malicious actor.
- Use timing-safe comparison. Standard string equality (
===) is vulnerable to timing attacks. Use crypto.timingSafeEqual (Node.js), hmac.compare_digest (Python), or subtle.ConstantTimeCompare (Go).
- Reject missing headers. Return HTTP
400 if X-Timestamp or X-Signature are absent.
- Validate the timestamp window. Optionally reject callbacks where
X-Timestamp is more than 5 minutes in the past to mitigate replay attacks.
- Return 200 quickly. Acknowledge the callback immediately and process the payload asynchronously to avoid timeouts on Star-Pay’s side.
- Never expose your webhook secret. Keep
CALLBACK_SECRET in an environment variable, not in source code.
Summary
| Security feature | Purpose |
|---|
| HMAC-SHA256 | Ensures the payload was not modified in transit |
| Timestamp in signature | Prevents replay attacks |
| Timing-safe comparison | Prevents timing side-channel attacks |
| Shared webhook secret | Authenticates that the sender is Star-Pay |