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.

Base address: 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

KindRequestReplyEndpoints
EncryptedPOST, an envelopean envelopeauth/login, auth/check-key, auth/heartbeat, auth/logout, app/slides, app/social-click, update/check
Encrypted replyGET, plainan envelopeapp/info, app/text
PlainPOST, plain JSONplain JSONauth/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

AnswerMeans
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_LIMITEDToo 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:

Envelope
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):

Python
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 JSON

Signed 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:

Signed text
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.

Public key (PEM)
-----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_keyThe key the customer typed.
hwidThis machine's id.
Reply (opened)
{
  "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.

EndpointFieldsReply
POST /api/auth/account-register.phpusername, password, email; license_key when sign-up needs a keyuser: id, username, email, status, key_type, duration, expires_at. Refusals: USERNAME_EXISTS, KEY_REQUIRED, REGISTRATION_DISABLED
POST /api/auth/account-login.phpusername, password, hwidLike 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.phpusername + password, or session_id; and license_keyAdds the key's time: days_added, lifetime, expires_at, days_remaining, max_devices. The key is used up.
POST /api/auth/change-password.phpusername, current_password, new_password{"success": true}. Every session of the account is signed out.

Moving to a new PC

EndpointFieldsWhat happens
POST /api/customer/reset-hwid.php plainkey, reasonSelf-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 plainlicense_key (or username), reasonA request you approve in HWID resets. The reply is the same whether or not the key exists.

Your app's content

EndpointSendsReply (opened)
GET /api/app/info.phpnothingapp: name, version, download_url, login_message, maintenance_mode, maintenance_msg, social_links; and changelog
GET /api/app/text.phpAuthorization: Bearer <licence key>; optional ?name=welcome_messagetexts (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.phplink_idCounts 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

Something missing or unclear? Tell us on Telegram.