Reseller API

Webhooks: we tell your system when something happens

Receive ZoltVPN events on your HTTPS endpoint: payload and headers, HMAC-SHA256 signature checks in Node.js, PHP and Python, retries and every event type.

Instead of asking us again and again, your system can receive events as they happen: a code redeemed, a device code used, a renewal, a client about to expire, and so on.

Set up. Portal → API → Webhooks → Add endpoint:

  • the HTTPS address on your server that should receive the events;
  • the events you want;
  • an optional description.

The signing secret (whsec_…) is shown once, like a key. You can have up to 5 endpoints. API access must be on for your account.

What arrives. A POST with a JSON body:

{
  "id": "6d1f0c2e-1b7a-4c33-9f0e-2a8b5d7c9e10",
  "type": "code.redeemed",
  "createdAt": "2026-11-05T18:00:00Z",
  "data": { "codeId": "…", "code": "ZP7K2M9Q4X1A", "planId": "…", "planName": "ZoltVPN Gold",
            "billingInterval": "monthly", "clientId": "…", "clientEmail": "[email protected]",
            "expiresAt": "2026-12-05T18:00:00Z" }
}

Headers:

HeaderValue
Zolt-Event-IdThe event's id. The same event sent again keeps its id.
Zolt-Event-TypeThe event's type.
Zolt-Delivery-IdThis attempt's delivery number.
Zolt-Signaturet=<unix seconds>,v1=<hex>.
User-AgentZoltVPN-Webhooks/1

Check the signature on every event:

  1. Take t and v1 from Zolt-Signature.
  2. Compute HMAC-SHA256 with your signing secret over the text t + "." + the raw request body, and hex-encode it.
  3. Compare it with v1 in constant time.
  4. Refuse the event if t is more than 5 minutes away from your clock.
// Node.js (Express: use express.raw({ type: "application/json" }) on this route)
const crypto = require("crypto");
function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 || ""));
}
// PHP
function zolt_verify(string $raw, string $header, string $secret): bool {
  parse_str(str_replace(',', '&', $header), $p);
  if (abs(time() - (int)($p['t'] ?? 0)) > 300) return false;
  $expected = hash_hmac('sha256', $p['t'] . '.' . $raw, $secret);
  return hash_equals($expected, $p['v1'] ?? '');
}
# Python
import hmac, hashlib, time
def verify(raw: bytes, header: str, secret: str) -> bool:
    p = dict(x.split("=", 1) for x in header.split(","))
    if abs(time.time() - int(p.get("t", 0))) > 300: return False
    expected = hmac.new(secret.encode(), f"{p['t']}.".encode() + raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, p.get("v1", ""))

Answering.

  • Answer any 2xx within 10 seconds. Do the slow work after answering.
  • Anything else counts as a failure, as does a time-out or a redirect (redirects are not followed). We try again after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h and 24 h: 8 attempts over about two days. After that the delivery is marked failed; you can send it again from the portal.
  • After 30 failed attempts in a row we turn the endpoint off and notify you. Turn it on again in the portal once it works.
  • An event can arrive more than once, or out of order. Use id to ignore repeats and createdAt to order them.
  • The address must be public HTTPS. Private and internal addresses are refused.

Events. Client events go to the client's own reseller (sub-agents get their own clients' events).

TypeWhendata
code.redeemedOne of your subscription codes (ZP…) was redeemedcodeId, code, planId, planName, billingInterval, clientId, clientEmail, expiresAt
device_code.redeemedAn extra-device code (ZD…) of one of your clients was used on a deviceslotId, code, clientId, clientEmail, deviceId, platform, model
client.createdA new client of yours: created by you (portal or API), or by redeeming your codeclientId, email, name, source (reseller / code)
subscription.renewedA client's plan started or was extendedclientId, email, planId, planName, expiresAt, source (reseller / code / client_payment)
subscription.expiringA client's plan ends in 7 days, and again at 1 dayclientId, email, planId, planName, expiresAt, daysLeft
subscription.expiredA client's plan ended and nothing replaced itclientId, email, planId, planName, expiredAt
client.paymentA client of yours paid us directlyclientId, email, paymentId, purpose (plan / device_slot / data_addon), amountCents, yourShareCents
client.data_limitA client used 80% or 100% of a data allowanceclientId, email, channel, level (80 / 100)
wallet.low_balanceYour wallet went below your low-balance alertbalanceCents, thresholdCents
api.blockedYour API was blocked for going over the daily limitblockedUntil, callsToday, perDay
webhook.testYou pressed Send test in the portalmessage