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 header.Three 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": [],
"texts": { "welcome_message": "Welcome back!" },
"slides": [],
"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; 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"}.
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/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), or with name: text_key and content |
POST /api/app/slides.php | {"action": "get_slides"} | slides: image and two lines 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.
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. 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.