Webhooks

Your server or a Discord channel hears about keys, sign-ups and orders the moment they happen.

A webhook is an address PWF Auth calls when something happens in one of your apps: a key is made, used for the first time, banned, a customer signs up, a store order arrives… Use it to update your own database, send a welcome email, or post to your team's Discord channel. Add addresses on the Webhooks page, up to 10 per app.

Add an address

  • Your server: an https:// address that answers with a 2xx status within 5 seconds. Each one gets a signing secret, shown once (copy it then; New secret makes another).
  • Discord channel: paste the channel's webhook address (channel settings → Integrations → Webhooks → Copy Webhook URL). Each event arrives as a short message.

Pick the events it should get, or Every event (new events are then included as they are added; ignore the ones you don't know). Test sends a test event at once and tells you what your address answered.

What arrives

HTTP
POST /your-webhook
Content-Type: application/json
X-Webhook-Id: whd_3f1c…            (the same on every retry of this delivery)
X-Webhook-Timestamp: 1760262000
X-Webhook-Attempt: 1
X-Webhook-Signature: hex HMAC-SHA256(body, secret)
X-Webhook-Signature-V2: t=1760262000,v1=hex HMAC-SHA256(t + "." + body, secret)

{"event": "key.created", "timestamp": "2026-10-12T10:00:00Z",
 "data": {"app_id": "…", "count": 2, "keys": ["…", "…"], "key_type": "days", "duration_value": 30, "max_devices": 1, "by": "panel"}}

by says where a change came from: panel, telegram, discord, reseller or store. Never an email address.

Check the signature

Anyone can call your address, so check every delivery before acting on it. Use the V2 header: it signs the time too, so an old delivery can't be replayed. Compare with a constant-time function, and skip an X-Webhook-Id you already handled.

PHP
$body = file_get_contents('php://input');
[$t, $v1] = sscanf($_SERVER['HTTP_X_WEBHOOK_SIGNATURE_V2'] ?? '', 't=%d,v1=%s');
$good = hash_hmac('sha256', $t . '.' . $body, getenv('PWF_WEBHOOK_SECRET'));
if (!$v1 || !hash_equals($good, $v1) || abs(time() - $t) > 300) { http_response_code(401); exit; }
$event = json_decode($body, true);
Python
import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    good = hmac.new(secret.encode(), parts["t"].encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(good, parts.get("v1", "")) and abs(time.time() - int(parts["t"])) < 300
Node.js
const crypto = require('crypto');
function verify(rawBody, header, secret) {
  const { t, v1 } = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const good = crypto.createHmac('sha256', secret).update(t + '.' + rawBody).digest('hex');
  return v1 && crypto.timingSafeEqual(Buffer.from(good), Buffer.from(v1)) && Math.abs(Date.now() / 1000 - t) < 300;
}

Events

EventWhendata
key.createdKeys made in the panel, by your bots, a reseller or a store ordercount, keys, key_type, duration_value, max_devices, by
key.activatedA key is used for the first time; its time startslicense_key
key.redeemedA user account adds a keylicense_key, username, added
key.banned · key.unbannedBanned or ban lifted (a store refund bans too)license_key, by, detail
key.paused · key.resumedPaused or resumedlicense_key, by
key.time_changedTime added or removedlicense_key, by, detail
key.hwid_resetThe key was freed for a new PClicense_key, by
key.deletedDeletedlicense_key, by
key.suspected_leakUsed from far-apart places in a short timelicense_key, anomalies
hwid_reset.requestedA customer asks you to move their keylicense_key, username, reason
account.registeredSomeone signs up in your appusername (license_key when they signed up with one)
trial.createdA free trial startstrial_key, duration_days, ip
order.completedA paid order from one of your stores made its keysprovider, store, order, product, email, quantity, amount, currency, test, keys
order.refundedA refund or chargeback banned an order's keysprovider, store, order, reason, keys
update.publishedYou publish an updateversion, channel, sha256, file_size, is_mandatory
app.revokedThe kill switch banned every key of the appby, detail

Every event's data also has app_id.

Retries

Deliveries leave within a minute. One that fails (no answer, or not 2xx) is tried again after 1 minute, 5 minutes, 30 minutes and 2 hours, with the same X-Webhook-Id and a higher X-Webhook-Attempt. An address that fails 20 times in a row is paused for you; press Resume once it is fixed. The page lists the last 40 deliveries with what your address answered, kept for 30 days.

Something missing or unclear? Tell us on Telegram.