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
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.
$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);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"])) < 300const 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
| Event | When | data |
|---|---|---|
| key.created | Keys made in the panel, by your bots, a reseller or a store order | count, keys, key_type, duration_value, max_devices, by |
| key.activated | A key is used for the first time; its time starts | license_key |
| key.redeemed | A user account adds a key | license_key, username, added |
| key.banned · key.unbanned | Banned or ban lifted (a store refund bans too) | license_key, by, detail |
| key.paused · key.resumed | Paused or resumed | license_key, by |
| key.time_changed | Time added or removed | license_key, by, detail |
| key.hwid_reset | The key was freed for a new PC | license_key, by |
| key.deleted | Deleted | license_key, by |
| key.suspected_leak | Used from far-apart places in a short time | license_key, anomalies |
| hwid_reset.requested | A customer asks you to move their key | license_key, username, reason |
| account.registered | Someone signs up in your app | username (license_key when they signed up with one) |
| trial.created | A free trial starts | trial_key, duration_days, ip |
| order.completed | A paid order from one of your stores made its keys | provider, store, order, product, email, quantity, amount, currency, test, keys |
| order.refunded | A refund or chargeback banned an order's keys | provider, store, order, reason, keys |
| update.published | You publish an update | version, channel, sha256, file_size, is_mandatory |
| app.revoked | The kill switch banned every key of the app | by, 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.