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:
| Header | Value |
|---|---|
Zolt-Event-Id | The event's id. The same event sent again keeps its id. |
Zolt-Event-Type | The event's type. |
Zolt-Delivery-Id | This attempt's delivery number. |
Zolt-Signature | t=<unix seconds>,v1=<hex>. |
User-Agent | ZoltVPN-Webhooks/1 |
Check the signature on every event:
- Take
tandv1fromZolt-Signature. - Compute HMAC-SHA256 with your signing secret over the text
t + "." + the raw request body, and hex-encode it. - Compare it with
v1in constant time. - Refuse the event if
tis 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
2xxwithin 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
idto ignore repeats andcreatedAtto 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).
| Type | When | data |
|---|---|---|
code.redeemed | One of your subscription codes (ZP…) was redeemed | codeId, code, planId, planName, billingInterval, clientId, clientEmail, expiresAt |
device_code.redeemed | An extra-device code (ZD…) of one of your clients was used on a device | slotId, code, clientId, clientEmail, deviceId, platform, model |
client.created | A new client of yours: created by you (portal or API), or by redeeming your code | clientId, email, name, source (reseller / code) |
subscription.renewed | A client's plan started or was extended | clientId, email, planId, planName, expiresAt, source (reseller / code / client_payment) |
subscription.expiring | A client's plan ends in 7 days, and again at 1 day | clientId, email, planId, planName, expiresAt, daysLeft |
subscription.expired | A client's plan ended and nothing replaced it | clientId, email, planId, planName, expiredAt |
client.payment | A client of yours paid us directly | clientId, email, paymentId, purpose (plan / device_slot / data_addon), amountCents, yourShareCents |
client.data_limit | A client used 80% or 100% of a data allowance | clientId, email, channel, level (80 / 100) |
wallet.low_balance | Your wallet went below your low-balance alert | balanceCents, thresholdCents |
api.blocked | Your API was blocked for going over the daily limit | blockedUntil, callsToday, perDay |
webhook.test | You pressed Send test in the portal | message |