Receive visit events

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:

HeaderMeaning
Content-Typeapplication/json
X-HighPay-EventEvent name: visit.started, visit.validated, or visit.rejected
X-HighPay-SignatureHex 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)

  1. 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.
  2. Read header X-HighPay-Signature (lowercase hex string).
  3. Compute HMAC-SHA256 of that raw body using your webhook secret as the key.
  4. Encode the HMAC result as lowercase hexadecimal.
  5. Compare your result to X-HighPay-Signature with a constant-time equals check (so attackers cannot guess the signature byte-by-byte via timing).
  6. If they match -> trust the payload and handle the event.
    If they do not match -> return 401 / 403 and 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_encode before 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

EventMeaningCredit user?
visit.startedEnd user opened smart_linkNo
visit.validatedVisit accepted; publisher balance creditedYes
visit.rejectedFailed antifraud / expired / rejectedNo

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.

Recent Requests
Log in to see full request history
TimeStatusUser Agent
Retrieving recent requests…
LoadingLoading…
Body Params

Body Highpay POSTs to your webhook URL. ad_id matches ads[].id from the ads endpoint
and the {id} used in visit status polling. Credit your end user only when
event / status is visit.validated / validated.

string
enum
required
Allowed:
date-time
required
string
required

Highpay publisher ID as string.

uuid
required

Same as visit / ad id.

string | null
string
enum
required

Lifecycle of a Smart Link API visit.

  • reserved — ad returned, smart link not opened yet
  • started — end user opened the offer's smart_link in their browser
  • validated — Highpay accepted the visit and credited the publisher
  • rejected — failed antifraud / campaign checks
  • expired — reservation timed out without a start
Allowed:
double
required
string
required
integer
required

Required dwell time in milliseconds.

string | null
Headers
string
required

Lowercase hex HMAC-SHA256 of the raw request body, keyed with your webhook secret
(whsec_… from the dashboard, not your API key). Recompute the same HMAC on your
server and compare with a constant-time equals check before trusting the payload.

string
enum
required

Event name for this delivery.

Allowed:
Response
200

Acknowledge receipt (any 2xx is treated as success)

Language
LoadingLoading…
Response
Click Try It! to start a request and see the response here!