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": [],
  "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

actionReply (opened)
getname, value (null when never set), exists
setname Makes the variable or replaces its value
deletename, deleted
listvars: 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

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) 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.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 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

Something missing or unclear? Tell us on Telegram