API reference
Every endpoint, the encrypted envelope and signed replies, for writing your own client
Everything your program can call, for when you write your own client instead of using an SDK. With the NuGet package, the Python package, the C++ client or the SDK generator you never build these requests by hand: they do the encryption, the signature check and the heartbeat for you
https://pwfauth.com. Every request is JSON over HTTPS and carries the app secret of your application (Applications → the app → Secret) in the X-App-Secret headerThree kinds of call
| Kind | Request | Reply | Endpoints |
|---|---|---|---|
| Encrypted | POST, an envelope | an envelope | auth/login, auth/check-key, auth/heartbeat, auth/logout, app/slides, app/social-click, update/check |
| Encrypted reply | GET, plain | an envelope | app/info, app/text |
| Plain | POST, plain JSON | plain JSON | auth/trial, auth/request-hwid-reset, auth/account-register, auth/account-login, auth/account-redeem, auth/change-password, customer/reset-hwid |
All paths start with /api/ and end in .php, e.g. https://pwfauth.com/api/auth/login.php. Sending a plain body to an encrypted endpoint, or an envelope to a plain one, is refused. A refusal that happens before the request is opened (a wrong secret, a bad envelope, a rate limit) is plain JSON even on an encrypted endpoint
The app secret
| Answer | Means |
|---|---|
401 {"detail":"Invalid app secret"} | The secret is wrong or belongs to no app |
403 {"detail":"CSRF validation failed…"} | No X-App-Secret header at all |
429 with RATE_LIMITED | Too many calls from this address; wait and try again |
The encrypted envelope
An envelope is a small JSON object. Both keys come from your app secret, so only your program and the server can open it:
encKey = SHA-256("enc:" + appSecret) // 32 bytes
macKey = SHA-256("mac:" + appSecret) // 32 bytes
iv = 16 random bytes
p = base64( iv + AES-256-CBC(encKey, iv, PKCS7, json) )
t = current Unix time in seconds
s = lowercase hex( HMAC-SHA256(macKey, p + t) ) // t as decimal text
body = {"p": "<p>", "t": <t>, "s": "<s>"}Open a reply the other way round: check s first (in constant time), refuse it when t is more than 300 seconds away from now, then decrypt p: the first 16 bytes are the IV. The server checks the same on your requests and answers 400 CRYPTO_ERROR otherwise. When the computer's clock is wrong the reply also has "reason": "CLOCK_SKEW" and server_time, so a client can correct its clock and send once more
A complete example in Python (pip install requests cryptography):
import base64, hashlib, hmac, json, os, time, requests
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
SECRET = "your-64-character-app-secret"
ENC = hashlib.sha256(("enc:" + SECRET).encode()).digest()
MAC = hashlib.sha256(("mac:" + SECRET).encode()).digest()
def seal(data):
iv = os.urandom(16)
padder = padding.PKCS7(128).padder()
raw = padder.update(json.dumps(data).encode()) + padder.finalize()
enc = Cipher(algorithms.AES(ENC), modes.CBC(iv)).encryptor()
p = base64.b64encode(iv + enc.update(raw) + enc.finalize()).decode()
t = int(time.time())
s = hmac.new(MAC, (p + str(t)).encode(), hashlib.sha256).hexdigest()
return {"p": p, "t": t, "s": s}
def open_reply(env):
expect = hmac.new(MAC, (env["p"] + str(env["t"])).encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expect, env["s"]) or abs(time.time() - env["t"]) > 300:
raise ValueError("reply refused")
blob = base64.b64decode(env["p"])
dec = Cipher(algorithms.AES(ENC), modes.CBC(blob[:16])).decryptor()
unpadder = padding.PKCS7(128).unpadder()
raw = unpadder.update(dec.update(blob[16:]) + dec.finalize()) + unpadder.finalize()
return json.loads(raw)
r = requests.post("https://pwfauth.com/api/auth/login.php",
headers={"X-App-Secret": SECRET, "Content-Type": "application/json"},
data=json.dumps(seal({"license_key": "XXXXX-XXXXX-XXXXX-XXXXX", "hwid": "this-pc-id"})))
reply = r.json()
print(open_reply(reply) if "p" in reply else reply) # a refusal before decryption is plain JSONSigned replies
The app secret lives inside your program, so on its own it cannot prove that a reply came from PWF Auth. Send a fresh X-PWF-Nonce header with every request (64 lowercase hex characters, 32 random bytes) and the reply carries X-PWF-Signature: base64 of an RSA-3072, PKCS#1 v1.5, SHA-256 signature over these seven lines, joined with \n:
PWF-REPLY-V1
<the X-PWF-Nonce you sent>
POST
/api/auth/login.php
<lowercase hex SHA-256 of the exact request body you sent>
<HTTP status, e.g. 200>
<lowercase hex SHA-256 of the exact reply body>Verify it with this public key, built into your program; refuse the reply when the header is missing or does not verify. The path is the request path without the query string, and the method is in capitals. Requests without the nonce are answered unsigned
-----BEGIN PUBLIC KEY-----
MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEArJgpZLCvg6z08gEFN4l/
x0FiSYQTsuff7bpb5BHFl7/263J0GOk4YCyt+kXftiURGN4J96KZ4ktAV2tgbu5p
D0LrUa97YLOOkDYIepzbYeSgL8CMQIiDkP86XEp/q2wC7y8weqyRV4fUV8SFNZ3M
gzwRI+aJxYyJGsPheK7Olq2HD5Dx+idjAz8PEbxLWqbNHnS2PdiZ34jC8darQqKI
EW65oBeE6ihulLLDqn1miV8tiEjorROyaCE3oScHzyHB7ZPH2YWcW7vYjgk9ED93
zy3/DrAwyfyWfK2jvb231OFI5+OWKR7XsErlOm3xgAwUkcN3kLxi5/tIbkq3bFOm
OJEr0hWO/nmUmlyCnBA400xjwEUajXmwILugv5QlLkhKtK31upMTTA9XEloerHyC
N4NUGx3+4NqNP07cFkTcH1PpinFRp/iVHLX6M2Zabd+V0rynn2X9STtWmRrKPMMB
AGV+hS8RMg4oliIqmf8FKooQ3HGaL7EoV4n0wgzXG4v9AgMBAAE=
-----END PUBLIC KEY-----Keys and sessions
POST /api/auth/login.php encrypted
Signs a licence key in on this PC and opens a session. The first sign-in binds the key to the HWID you send (any id that stays the same on that machine; the server keeps only a hash of it, which is what user.hwid shows)
| Field | |
|---|---|
license_key | The key the customer typed |
hwid | This machine's id |
{
"success": true,
"message": "License activated successfully!",
"session_id": "0b43d004-0f73-4492-82f5-f185c6c5e7d0",
"user": {
"license_key": "DEMO-1111-2222-3333",
"key_type": "days",
"duration": 30,
"hwid": "314029d338eec611f2bc8c03…",
"activated_at": "2026-10-12T01:14:34Z",
"expires_at": "2026-11-11T01:14:34Z",
"days_remaining": 30,
"status": "active"
},
"seller": { "type": "developer", "name": "Demo App", "contact": null },
"app": { "name": "Demo App", "version": "1.0.0", "message": "Welcome!" },
"features": [],
"addons": [ { "code": "night_city", "name": "Night City map pack", "lifetime": true, "expires_at": null, "days_remaining": null } ],
"texts": { "welcome_message": "Welcome back!" },
"texts_updated": { "welcome_message": "2026-10-30T18:02:11Z" },
"slides": [ { "image_url": "https://example.com/v2.png", "line1": "Version 2 is out", "line2": "", "updated_at": "2026-10-31T09:40:00Z" } ],
"heartbeat_interval": 30
}Keep session_id for the heartbeat and sign-out, and check in every heartbeat_interval seconds. texts are your texts for this key, texts_updated when each one last changed and a slide's updated_at when it did (UTC); features are the key's feature flags. Refusals: error codes such as INVALID_KEY, HWID_MISMATCH, DEVICE_LIMIT, EXPIRED, BANNED, PAUSED, MAINTENANCE
POST /api/auth/heartbeat.php encrypted
Fields: session_id, license_key (for an account session, leave it out or empty). Reply: {"success": true, "message": "Heartbeat received", "addons": [...]}, with the add-ons the customer has now, so one you give or take in the panel reaches the program within a heartbeat
This is the kill switch: when a reply says "success": false (BANNED, PAUSED, EXPIRED, HWID_RESET, SESSION_REVOKED, SESSION_EXPIRED, MAINTENANCE…), show its message and close the app. Count a call that gets no opened reply as a miss, and close after three misses in a row
POST /api/auth/check-key.php encrypted
Looks a key up without signing in or binding it. Field: license_key. Reply: valid, and key with status, key_type, duration, activated_at, expires_at, days_remaining, max_devices
POST /api/auth/logout.php encrypted
Fields: session_id, license_key. Ends the session; call it when the program closes
POST /api/auth/resource.php encrypted
A protected file: a file your program needs that is kept on the server and given only to a signed-in program (App settings → Files in the panel) Fields: session_id, name Reply (opened): name, size, sha256 (of those bytes), is_text, is_module (a .NET dll the program loads from memory), data (the file, base64). Each customer's copy carries their own mark, so a copy that turns up leads back to a key. Without a name you get the list of names Refusals: SESSION_EXPIRED, NOT_FOUND, RATE_LIMITED (60 an hour for a session) The .NET package has GetResourceAsync and LoadModuleAsync, which loads a module with Assembly.Load(byte[]): the part of your program that matters is then not in the update at all
POST /api/auth/redeem-addon.php encrypted
Adds an add-on key on the session the program is signed in with (a licence key or an account). See App settings. Fields: session_id, addon_key. Reply (opened): addon (code, name, lifetime, expires_at, days_remaining) and addons, every add-on the customer has now
Refusals: SESSION_EXPIRED, ADDON_KEY_NOT_FOUND, ADDON_KEY_USED, ADDON_UNAVAILABLE, MAINTENANCE. Wrong add-on keys count against the address like wrong licence keys. The sign-in replies (login, account-login), check-key and every heartbeat carry the same addons list; POST /api/auth/addons.php with session_id returns it on its own
POST /api/auth/var.php encrypted
User variables: values the program keeps on the server for the signed-in customer Fields: session_id, action (get, set, delete or list), name, and value for set
| action | Reply (opened) |
|---|---|
get | name, value (null when never set), exists |
set | name Makes the variable or replaces its value |
delete | name, deleted |
list | vars: every name and value of this customer |
Needs a live session of a key or an account, else SESSION_EXPIRED Refusals: INVALID_NAME, VALUE_TOO_LONG, TOO_MANY_VARS Limits: 50 names a customer, 4 KB a value
POST /api/auth/trial.php plain
A free trial for this machine, when trials are on for the app. Field: hwid. Reply: trial_key, session_id, expires_at, days_remaining, user, heartbeat_interval, already signed in. One trial per machine (TRIAL_USED); TRIAL_DISABLED when trials are off
Customer accounts
A username and a password instead of a key, when customer accounts are on for the app. All plain JSON
| Endpoint | Fields | Reply |
|---|---|---|
POST /api/auth/account-register.php | username, password, email; license_key when sign-up needs a key | user: id, username, email, status, key_type, duration, expires_at. Refusals: USERNAME_EXISTS, KEY_REQUIRED, REGISTRATION_DISABLED |
POST /api/auth/account-login.php | username, password, hwid | Like a key sign-in: session_id, user, app, slides, heartbeat_interval. Then beat with the session_id and no key |
POST /api/auth/account-redeem.php | username + password, or session_id; and license_key | Adds the key's time: days_added, lifetime, expires_at, days_remaining, max_devices. The key is used up |
POST /api/auth/change-password.php | username, current_password, new_password | {"success": true}. Every session of the account is signed out |
Moving to a new PC
| Endpoint | Fields | What happens |
|---|---|---|
POST /api/customer/reset-hwid.php plain | key, reason | Self-service, when you allow it: the key is unbound at once and binds to the next PC that signs in. Reply has next_reset_at. Refusals: SELF_RESET_DISABLED, RATE_LIMITED, NO_HWID |
POST /api/auth/request-hwid-reset.php plain | license_key (or username), reason | A request you approve in HWID resets. The reply is the same whether or not the key exists |
Your app's content
| Endpoint | Sends | Reply (opened) |
|---|---|---|
GET /api/app/info.php | nothing | app: name, version, download_url, login_message, maintenance_mode, maintenance_msg, social_links; and changelog |
GET /api/app/text.php | Authorization: Bearer <licence key>; optional ?name=welcome_message | texts (every text, this key's own values first) and texts_updated (when each last changed), or with name: text_key, content and updated_at |
POST /api/app/slides.php | {"action": "get_slides"} | slides: image, two lines and updated_at each, in order |
POST /api/app/social-click.php | link_id | Counts a click on one of the app's links |
Updates
POST /api/update/check.php encrypted
Fields: current_version (or v), channel (stable by default), hwid, license_key; optional os, arch. Without a newer build: {"success": true, "update_available": false}. With one, update holds version, channel, changelog, release_notes_url, is_mandatory, mandatory_after, file_size, sha256, min_app_version, download_url and files With the leak tracer on, every customer's copy carries a mark of their own, so send license_key: sha256 and file_size are then those of the copy that key will get (null when no key is sent)
GET /api/update/download.php
?v=<version> with X-App-Secret, and the customer's key (&key= or X-License-Key) or session (&session= or X-Session-Id). Returns the file itself; check its SHA-256 against sha256 before you run it (the same hash is in the X-Content-SHA256 header). More in Updates
More
- Error codes: every
error_code, and what to tell the customer - How protection works: sessions, the heartbeat and machine binding
- Reseller API: for the tools your resellers write
Something missing or unclear? Tell us on Telegram