$ feature / webhooks
Real-time HTTP for license events.
Every license lifecycle action emits a webhook. Configure an endpoint per app (or several), receive signed JSON payloads in real time, and pipe the events wherever you want: a Slack channel for support visibility, a Postgres table for analytics, your own backend for fulfilling licenses against orders, or a Discord bot for "new sale" pings.
Event catalog
license.created
A new license has been issued (manually from the dashboard, programmatically through the developer API, or automatically by a Stripe / Lemon Squeezy commerce flow). Payload includes the license key, the issuing app id, and any metadata you attached at creation time.
license.validated
A successful validation has been processed. Use this for analytics ('how many active users do I have today'), session monitoring, or to push a row into your own data warehouse. Failed validations are not delivered as events.
license.revoked
A license has been revoked, either manually, through a refund event, or by the self-ban flow. Use this to lock the customer out of any additional services your backend exposes.
license.activated
A license has just been bound to its first HWID. Useful for first-launch analytics ('this user actually opened the app') or for sending a welcome email with onboarding tips.
license.hwid_bound
A new HWID has been added to a license's seat list (filling slot N of M). The payload includes the slot index, the new HWID hash, and the source IP that bound it.
license.hwid_reset
All HWIDs have been cleared from a license. Useful for audit trails (who reset what, when) and for triggering a tier-2 review if the same license is reset frequently.
license.deleted
A license has been permanently deleted. This is irreversible: the key cannot be reactivated or rebound after this event fires.
license.offline_file_minted
An offline .authforge license file was minted. Payload includes the license key, file id (jti), expiry, HWID policy, signing key id, file SHA-256, and source (dashboard or developer-api). The armored file, payload, and signature are never included: the server does not store or re-emit the file body. Idempotent replays of the same mint do not fire this event.
Signed payloads (HMAC-SHA256)
Each delivery includes an X-AuthForge-Signature-V2 header of
the form t=<unix seconds>,v1=<hex>, where the hex
value is the HMAC-SHA256 of t, a dot, and the raw request
body, keyed by your webhook secret. Compute the same HMAC on your side, do
a constant-time compare, and reject anything that doesn't match or whose
t is more than five minutes old. This stops anyone who finds
your webhook URL from forging an event, and anyone who captures a delivery
from replaying it later. Each event also has a stable id in the JSON
body and an X-AuthForge-Event-Id header (evt_ plus hex).
Retries send that same id and only change t, so deduplicate on the id.
// Node.js example using the standard crypto module
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
// header is the X-AuthForge-Signature-V2 value: "t=<unix seconds>,v1=<hex>"
function verifyWebhook(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(",").map((part) => part.trim().split("=", 2)),
);
const t = Number(parts.t);
if (!Number.isInteger(t) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(t + ".")
.update(rawBody)
.digest();
const received = Buffer.from(parts.v1, "hex");
return received.length === expected.length &&
crypto.timingSafeEqual(received, expected);
}
The older X-AuthForge-Signature header (the HMAC of the body
alone) is still sent so existing integrations keep working, but it
doesn't cover the timestamp. Switch to the V2 header when you can.
The webhook secret is generated when you create the endpoint in the dashboard (App settings → Webhooks → Add Webhook) and is shown exactly once. Rotate it whenever a developer leaves your team.
Test delivery
Each webhook endpoint has a Test button in the dashboard. It
sends a test.ping event, whose data holds the
webhookId and appId, to your endpoint with the
same headers as a real event, signed with your real webhook secret, and
shows the HTTP status your endpoint returned. Use it to check that your
URL is reachable and your signature verification works before a real
event arrives.
Retries
Each attempt waits up to 5 seconds for a response, so acknowledge with a 2xx quickly and do slow work afterwards. Failed deliveries (a network error, a timeout, or a 5xx response) are retried up to three times, waiting about 1, 5, and then 25 seconds between attempts. Redirects are not followed; a 3xx response counts as a failed delivery. The dashboard shows each endpoint's last delivery time and HTTP status. If the last retry also fails, the event is dropped, so alert on your own handler errors rather than relying on redelivery.
An endpoint that fails ten events in a row, every retry included, is switched off automatically. The dashboard marks it as auto-disabled and the app owner is emailed. Fix the endpoint and check it with a test ping, then edit the webhook and re-enable it. Re-enabling keeps the failure count until a delivery succeeds, so if the next event also fails the webhook is switched off again straight away. Events that fired while it was off are not replayed.
Each event carries a unique id. Retries can deliver the same
event more than once, so make your handler idempotent keyed on that id.
Related
-
Commerce integrations
:
Stripe and Lemon Squeezy fire
license.createdon successful checkout. - REST API reference : full event payload shape for every event above.
- Security overview