Direction: Highpay calls you. Configure the URL in the publisher dashboard
under Smart Link API -> Webhooks. This path is illustrative, use whatever URL you host.
Highpay sends a POST with a JSON body and these headers:
| Header | Meaning |
|---|---|
Content-Type | application/json |
X-HighPay-Event | Event name: visit.started, visit.validated, or visit.rejected |
X-HighPay-Signature | Hex HMAC-SHA256 of the raw request body using your webhook secret (whsec_…) |
Why verify the signature?
Anyone who discovers your webhook URL could POST fake JSON and trick you into crediting users.
The signature proves the request really came from Highpay and that the body was not modified in transit.
When you create a webhook in the dashboard, Highpay shows a secret once
(starts with whsec_…). Store it on your server only. Highpay signs every delivery with that secret; your server recomputes the same signature and compares.
How verification works (step by step)
- Read the raw HTTP body as bytes/string before parsing JSON.
Do not rebuild JSON from a decoded object. Key order or spacing would change the bytes and the signature would not match. - Read header
X-HighPay-Signature(lowercase hex string). - Compute HMAC-SHA256 of that raw body using your webhook secret as the key.
- Encode the HMAC result as lowercase hexadecimal.
- Compare your result to
X-HighPay-Signaturewith a constant-time equals check (so attackers cannot guess the signature byte-by-byte via timing). - If they match -> trust the payload and handle the event.
If they do not match -> return401/403and do not credit anyone.
Pseudocode
rawBody = request.rawBody() // exact bytes Highpay sent
signature = request.header("X-HighPay-Signature")
secret = env("HIGHPAY_WEBHOOK_SECRET") // whsec_…
expected = hex( HMAC_SHA256(key=secret, message=rawBody) )
if not constant_time_equals(expected, signature):
reject 401
payload = json.parse(rawBody)
// handle payload.event / payload.status
Examples
Node.js (Express): use the raw body buffer:
const crypto = require("crypto");
function verifyHighpayWebhook(rawBody, signatureHeader, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody) // Buffer or string, unmodified
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(signatureHeader || "", "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// app.post("/webhooks/highpay", express.raw({ type: "application/json" }), (req, res) => {
// if (!verifyHighpayWebhook(req.body, req.get("X-HighPay-Signature"), process.env.HIGHPAY_WEBHOOK_SECRET)) {
// return res.status(401).send("invalid signature");
// }
// const payload = JSON.parse(req.body.toString("utf8"));
// // ...
// res.sendStatus(200);
// });PHP
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_HIGHPAY_SIGNATURE'] ?? '';
$secret = getenv('HIGHPAY_WEBHOOK_SECRET'); // whsec_…
$expected = hash_hmac('sha256', $rawBody, $secret);
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit('invalid signature');
}
$payload = json_decode($rawBody, true);
// handle $payload['event'] / $payload['status']Python
import hmac, hashlib
def verify(raw_body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature or "")Common mistakes that cause “invalid signature”:
- Parsing JSON first, then re-
JSON.stringify/json_encodebefore hashing - Using a query string, pretty-printed body, or trimmed body instead of the raw body
- Comparing with
===/==instead of a timing-safe helper - Using the API key (
hp_live_…) instead of the webhook secret (whsec_…)
When to credit your user
| Event | Meaning | Credit user? |
|---|---|---|
visit.started | End user opened smart_link | No |
visit.validated | Visit accepted; publisher balance credited | Yes |
visit.rejected | Failed antifraud / expired / rejected | No |
Respond with any 2xx to acknowledge. Non-2xx responses are retried with backoff.
You can also poll GET /api/v1/smart-links/visits/{id} instead of (or as backup to) webhooks.
| Time | Status | User Agent | |
|---|---|---|---|
Retrieving recent requests… | |||
200Acknowledge receipt (any 2xx is treated as success)
